mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(ui): add comprehensive skeleton loaders for contacts and todo apps
- Add shared skeleton primitives to @manacore/shared-ui: - SkeletonAvatar, SkeletonCard, SkeletonRow, SkeletonList, SkeletonGrid - Add contacts app skeletons: - AppLoadingSkeleton for root layout auth loading - ContactListSkeleton, ContactGridSkeleton for contact views - ContactDetailSkeleton for modal loading - ContactNotesSkeleton for notes section - TagGridSkeleton, FavoriteGridSkeleton for respective pages - DuplicateListSkeleton for duplicates page - ImportPreviewSkeleton, GoogleImportSkeleton for import flows - NetworkGraphSkeleton for network visualization - Add todo app skeletons: - AppLoadingSkeleton for root layout - TaskListSkeleton with configurable sections - StatisticsSkeleton for stats page charts - KanbanBoardSkeleton, KanbanColumnSkeleton for kanban view - Replace all spinner loading indicators with skeleton loaders - All skeletons include proper accessibility attributes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
99c28242c5
commit
b6158a89a6
47 changed files with 2303 additions and 111 deletions
|
|
@ -3,6 +3,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||
import ContactNotes from './ContactNotes.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
|
|
@ -276,25 +277,7 @@
|
|||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<svg class="spinner-lg" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<p class="loading-text">Lade Kontakt...</p>
|
||||
</div>
|
||||
<ContactDetailSkeleton />
|
||||
{:else if error && !contact}
|
||||
<div class="error-container">
|
||||
<div class="error-icon-wrapper">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import ContactListView from '$lib/components/views/ContactListView.svelte';
|
||||
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
|
||||
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
|
||||
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
|
||||
import { batchApi } from '$lib/api/batch';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
|
|
@ -453,13 +454,13 @@
|
|||
<ViewModeToggle />
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<!-- Loading state with skeleton -->
|
||||
{#if contactsStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
<ContactGridSkeleton count={8} />
|
||||
{:else}
|
||||
<ContactListSkeleton count={10} />
|
||||
{/if}
|
||||
{:else if contactsStore.contacts.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { notesApi, type ContactNote } from '$lib/api/contacts';
|
||||
import { ContactNotesSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
|
|
@ -197,9 +198,7 @@
|
|||
|
||||
<!-- Notes List -->
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
</div>
|
||||
<ContactNotesSkeleton />
|
||||
{:else if notes.length === 0 && !showAddForm}
|
||||
<div class="empty-notes">
|
||||
<p>{$_('notes.empty')}</p>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
type GoogleImportResult,
|
||||
} from '$lib/api/google';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { GoogleImportSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
type Step = 'connect' | 'select' | 'result';
|
||||
|
||||
|
|
@ -170,12 +171,7 @@
|
|||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">{$_('google.loading')}</p>
|
||||
</div>
|
||||
<GoogleImportSkeleton />
|
||||
{:else if step === 'connect'}
|
||||
<!-- Connect Step -->
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppLoadingSkeleton - Full page loading skeleton for initial app load
|
||||
* Shows a minimal skeleton layout while auth is being checked
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
|
||||
<!-- Header placeholder -->
|
||||
<div class="header-skeleton">
|
||||
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
|
||||
<div class="header-nav">
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<!-- Content placeholder -->
|
||||
<div class="content-skeleton">
|
||||
<!-- Page title -->
|
||||
<div class="title-row">
|
||||
<SkeletonBox width="200px" height="32px" />
|
||||
<SkeletonBox width="120px" height="40px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<SkeletonBox width="100%" height="48px" borderRadius="12px" />
|
||||
|
||||
<!-- List items -->
|
||||
<div class="list-skeleton">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="list-item" style="opacity: {Math.max(0.3, 1 - i * 0.15)};">
|
||||
<SkeletonBox width="48px" height="48px" borderRadius="50%" />
|
||||
<div class="item-content">
|
||||
<SkeletonBox width="60%" height="18px" />
|
||||
<SkeletonBox width="40%" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-loading-skeleton {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.list-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactCardSkeleton - Skeleton for a single contact grid card
|
||||
* Matches the layout of ContactGridView.svelte
|
||||
*/
|
||||
|
||||
import { SkeletonBox, SkeletonAvatar } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
let { opacity = 1 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="contact-card-skeleton" style="opacity: {opacity};" role="status" aria-label="Laden...">
|
||||
<!-- Favorite button placeholder (top right) -->
|
||||
<div class="absolute top-3 right-3">
|
||||
<SkeletonBox width="24px" height="24px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<SkeletonAvatar size="100px" />
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="w-full space-y-2 mt-4 text-center">
|
||||
<!-- Name -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="70%" height="18px" />
|
||||
</div>
|
||||
<!-- Job Title -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="50%" height="14px" />
|
||||
</div>
|
||||
<!-- Company -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="40%" height="12px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid-actions">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="50%" />
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="50%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-card-skeleton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.grid-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactDetailSkeleton - Skeleton for contact detail modal loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="contact-detail-skeleton" role="status" aria-label="Kontakt wird geladen...">
|
||||
<!-- Profile Header -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-wrapper">
|
||||
<SkeletonBox width="100px" height="100px" borderRadius="50%" />
|
||||
</div>
|
||||
<SkeletonBox width="180px" height="24px" />
|
||||
<SkeletonBox width="140px" height="16px" />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
{#each Array(3) as _}
|
||||
<div class="quick-action-skeleton">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="50%" />
|
||||
<SkeletonBox width="48px" height="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Details Sections -->
|
||||
<div class="details-container">
|
||||
<!-- Contact Section -->
|
||||
<div class="detail-section">
|
||||
<div class="section-header-skeleton">
|
||||
<SkeletonBox width="28px" height="28px" borderRadius="6px" />
|
||||
<SkeletonBox width="80px" height="16px" />
|
||||
</div>
|
||||
<div class="detail-items">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="detail-item" style="opacity: {Math.max(0.5, 1 - i * 0.15)};">
|
||||
<div class="detail-content">
|
||||
<SkeletonBox width="50px" height="12px" />
|
||||
<SkeletonBox width="65%" height="16px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Work Section -->
|
||||
<div class="detail-section">
|
||||
<div class="section-header-skeleton">
|
||||
<SkeletonBox width="28px" height="28px" borderRadius="6px" />
|
||||
<SkeletonBox width="60px" height="16px" />
|
||||
</div>
|
||||
<div class="detail-items">
|
||||
{#each Array(2) as _, i}
|
||||
<div class="detail-item" style="opacity: {Math.max(0.5, 1 - i * 0.2)};">
|
||||
<div class="detail-content">
|
||||
<SkeletonBox width="45px" height="12px" />
|
||||
<SkeletonBox width="55%" height="16px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="detail-section">
|
||||
<div class="section-header-skeleton">
|
||||
<SkeletonBox width="28px" height="28px" borderRadius="6px" />
|
||||
<SkeletonBox width="70px" height="16px" />
|
||||
</div>
|
||||
<div class="address-skeleton">
|
||||
<SkeletonBox width="75%" height="16px" />
|
||||
<SkeletonBox width="50%" height="16px" />
|
||||
<SkeletonBox width="40%" height="16px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-detail-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.quick-action-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.875rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.5);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.detail-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.3);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.address-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactGridSkeleton - Skeleton for the contact grid view
|
||||
* Shows multiple ContactCardSkeleton with cascading fade effect
|
||||
*/
|
||||
|
||||
import ContactCardSkeleton from './ContactCardSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton cards to show */
|
||||
count?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
}
|
||||
|
||||
let { count = 8, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="contact-grid-skeleton" role="status" aria-label="Kontakte werden geladen...">
|
||||
{#each Array(count) as _, i}
|
||||
<ContactCardSkeleton opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-grid-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.contact-grid-skeleton {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.contact-grid-skeleton {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.contact-grid-skeleton {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactListSkeleton - Skeleton for the contact list view
|
||||
* Shows multiple ContactRowSkeleton with cascading fade effect
|
||||
*/
|
||||
|
||||
import ContactRowSkeleton from './ContactRowSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton rows to show */
|
||||
count?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
}
|
||||
|
||||
let { count = 8, fadeEffect = true, minOpacity = 0.3 }: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2" role="status" aria-label="Kontakte werden geladen...">
|
||||
{#each Array(count) as _, i}
|
||||
<ContactRowSkeleton opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactNotesSkeleton - Skeleton for contact notes loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="notes-skeleton" role="status" aria-label="Notizen werden geladen...">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="note-item" style="opacity: {Math.max(0.4, 1 - i * 0.25)};">
|
||||
<div class="note-content">
|
||||
<SkeletonBox width="85%" height="14px" />
|
||||
<SkeletonBox width="60%" height="14px" />
|
||||
</div>
|
||||
<SkeletonBox width="50px" height="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notes-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactRowSkeleton - Skeleton for a single contact list row
|
||||
* Matches the layout of ContactListView.svelte
|
||||
*/
|
||||
|
||||
import { SkeletonBox, SkeletonAvatar } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
let { opacity = 1 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="contact-row-skeleton" style="opacity: {opacity};" role="status" aria-label="Laden...">
|
||||
<!-- Avatar -->
|
||||
<SkeletonAvatar size="48px" />
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<!-- Name -->
|
||||
<SkeletonBox width="55%" height="18px" />
|
||||
<!-- Job/Company -->
|
||||
<SkeletonBox width="40%" height="14px" />
|
||||
<!-- Email -->
|
||||
<SkeletonBox width="65%" height="14px" />
|
||||
</div>
|
||||
|
||||
<!-- Favorite button placeholder -->
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-row-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* DuplicateGroupSkeleton - Skeleton for a duplicate group card
|
||||
* Matches the duplicates page layout
|
||||
*/
|
||||
|
||||
import { SkeletonBox, SkeletonAvatar } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Number of contact placeholders to show */
|
||||
contactCount?: number;
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
let { contactCount = 3, opacity = 1 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="duplicate-group-skeleton"
|
||||
style="opacity: {opacity};"
|
||||
role="status"
|
||||
aria-label="Laden..."
|
||||
>
|
||||
<!-- Group header -->
|
||||
<div class="group-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="8px" />
|
||||
<div class="space-y-2">
|
||||
<SkeletonBox width="200px" height="16px" />
|
||||
<SkeletonBox width="140px" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBox width="120px" height="36px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Contacts preview -->
|
||||
<div class="group-content">
|
||||
<div class="contacts-grid">
|
||||
{#each Array(contactCount) as _, i}
|
||||
<div class="contact-preview">
|
||||
<SkeletonAvatar size="40px" />
|
||||
<div class="space-y-1.5 flex-1 min-w-0">
|
||||
<SkeletonBox width="70%" height="14px" />
|
||||
<SkeletonBox width="50%" height="12px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.duplicate-group-skeleton {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.group-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.contacts-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contact-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* DuplicateListSkeleton - Skeleton for the duplicates page
|
||||
* Shows stats cards and duplicate groups with fade effect
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of duplicate groups to show */
|
||||
count?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
}
|
||||
|
||||
let { count = 3, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6" role="status" aria-label="Duplikate werden geladen...">
|
||||
<!-- Stats skeleton -->
|
||||
<div class="stats-grid">
|
||||
{#each Array(3) as _}
|
||||
<div class="stat-card">
|
||||
<SkeletonBox width="60px" height="28px" />
|
||||
<SkeletonBox width="100px" height="14px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Duplicate groups skeleton -->
|
||||
<div class="space-y-4">
|
||||
{#each Array(count) as _, i}
|
||||
<DuplicateGroupSkeleton contactCount={2 + (i % 2)} opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* FavoriteCardSkeleton - Skeleton for a favorite contact card
|
||||
* Matches the FavoriteCardView layout
|
||||
*/
|
||||
|
||||
import { SkeletonBox, SkeletonAvatar } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
let { opacity = 1 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="favorite-card-skeleton" style="opacity: {opacity};" role="status" aria-label="Laden...">
|
||||
<!-- Header with gradient background placeholder -->
|
||||
<div class="card-header">
|
||||
<SkeletonAvatar size="80px" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="card-content">
|
||||
<!-- Name -->
|
||||
<div class="flex justify-center mb-2">
|
||||
<SkeletonBox width="70%" height="20px" />
|
||||
</div>
|
||||
|
||||
<!-- Job/Company -->
|
||||
<div class="flex justify-center mb-1">
|
||||
<SkeletonBox width="50%" height="14px" />
|
||||
</div>
|
||||
|
||||
<!-- Contact info -->
|
||||
<div class="flex justify-center gap-2 mt-3">
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorite-card-skeleton {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.5) 100%);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* FavoriteGridSkeleton - Skeleton for the favorites grid
|
||||
*/
|
||||
|
||||
import FavoriteCardSkeleton from './FavoriteCardSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton cards to show */
|
||||
count?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
}
|
||||
|
||||
let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="favorite-grid-skeleton" role="status" aria-label="Favoriten werden geladen...">
|
||||
{#each Array(count) as _, i}
|
||||
<FavoriteCardSkeleton opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorite-grid-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.favorite-grid-skeleton {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.favorite-grid-skeleton {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.favorite-grid-skeleton {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* GoogleImportSkeleton - Skeleton for Google contacts loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="space-y-6" role="status" aria-label="Google-Kontakte werden geladen...">
|
||||
<!-- Connected Account Card Skeleton -->
|
||||
<div class="bg-card rounded-lg p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="50%" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<SkeletonBox width="120px" height="16px" />
|
||||
<SkeletonBox width="180px" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
</div>
|
||||
|
||||
<!-- Contact List Skeleton -->
|
||||
<div class="bg-card rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<SkeletonBox width="140px" height="18px" />
|
||||
<div class="flex gap-2 items-center">
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<SkeletonBox width="70px" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-border">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="flex items-center gap-4 p-4" style="opacity: {Math.max(0.3, 1 - i * 0.12)};">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="50%" />
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<SkeletonBox width="50%" height="16px" />
|
||||
<SkeletonBox width="65%" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button Skeleton -->
|
||||
<div class="flex justify-end">
|
||||
<SkeletonBox width="160px" height="42px" borderRadius="8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ImportPreviewSkeleton - Skeleton for file processing/preview loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="import-preview-skeleton" role="status" aria-label="Datei wird verarbeitet...">
|
||||
<!-- Header stats -->
|
||||
<div class="stats-row">
|
||||
{#each Array(3) as _}
|
||||
<div class="stat-card">
|
||||
<SkeletonBox width="48px" height="32px" />
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Preview list -->
|
||||
<div class="preview-list">
|
||||
<div class="list-header">
|
||||
<SkeletonBox width="150px" height="20px" />
|
||||
<SkeletonBox width="100px" height="14px" />
|
||||
</div>
|
||||
|
||||
<div class="list-items">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="list-item" style="opacity: {Math.max(0.3, 1 - i * 0.15)};">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<div class="item-content">
|
||||
<SkeletonBox width="45%" height="16px" />
|
||||
<SkeletonBox width="60%" height="14px" />
|
||||
</div>
|
||||
<SkeletonBox width="60px" height="24px" borderRadius="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="actions">
|
||||
<SkeletonBox width="100px" height="40px" borderRadius="8px" />
|
||||
<SkeletonBox width="140px" height="40px" borderRadius="8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.import-preview-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.list-items {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="network-skeleton">
|
||||
<!-- Simulated graph nodes -->
|
||||
<div class="skeleton-nodes">
|
||||
{#each Array(8) as _, i}
|
||||
<div
|
||||
class="skeleton-node"
|
||||
style="
|
||||
left: {20 + Math.random() * 60}%;
|
||||
top: {20 + Math.random() * 60}%;
|
||||
animation-delay: {i * 0.1}s;
|
||||
"
|
||||
>
|
||||
<SkeletonBox width="48px" height="48px" borderRadius="50%" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Simulated connections -->
|
||||
<svg class="skeleton-connections" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line x1="25" y1="30" x2="45" y2="50" class="skeleton-line" />
|
||||
<line x1="45" y1="50" x2="70" y2="35" class="skeleton-line" style="animation-delay: 0.2s" />
|
||||
<line x1="45" y1="50" x2="55" y2="70" class="skeleton-line" style="animation-delay: 0.4s" />
|
||||
<line x1="70" y1="35" x2="80" y2="55" class="skeleton-line" style="animation-delay: 0.6s" />
|
||||
<line x1="30" y1="65" x2="55" y2="70" class="skeleton-line" style="animation-delay: 0.8s" />
|
||||
</svg>
|
||||
|
||||
<!-- Loading text -->
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
<span>Netzwerk wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-skeleton {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-nodes {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.skeleton-node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-connections {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
stroke: hsl(var(--muted-foreground) / 0.2);
|
||||
stroke-width: 0.5;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TagCardSkeleton - Skeleton for a single tag card
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
let { opacity = 1 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="tag-card-skeleton" style="opacity: {opacity};" role="status" aria-label="Laden...">
|
||||
<!-- Color dot + Name -->
|
||||
<div class="flex items-center gap-3">
|
||||
<SkeletonBox width="12px" height="12px" borderRadius="50%" />
|
||||
<SkeletonBox width="60%" height="18px" />
|
||||
</div>
|
||||
|
||||
<!-- Contact count -->
|
||||
<div class="mt-2">
|
||||
<SkeletonBox width="40%" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-card-skeleton {
|
||||
padding: 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TagGridSkeleton - Skeleton for the tags grid
|
||||
*/
|
||||
|
||||
import TagCardSkeleton from './TagCardSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton cards to show */
|
||||
count?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
}
|
||||
|
||||
let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tag-grid-skeleton" role="status" aria-label="Tags werden geladen...">
|
||||
{#each Array(count) as _, i}
|
||||
<TagCardSkeleton opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-grid-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tag-grid-skeleton {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.tag-grid-skeleton {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
apps/contacts/apps/web/src/lib/components/skeletons/index.ts
Normal file
42
apps/contacts/apps/web/src/lib/components/skeletons/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Contacts App Skeleton Components
|
||||
*
|
||||
* App-specific skeleton loaders that match the exact layout of contact components.
|
||||
* Built on top of @manacore/shared-ui skeleton primitives.
|
||||
*/
|
||||
|
||||
// Contact List/Grid Skeletons
|
||||
export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte';
|
||||
export { default as ContactListSkeleton } from './ContactListSkeleton.svelte';
|
||||
export { default as ContactCardSkeleton } from './ContactCardSkeleton.svelte';
|
||||
export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte';
|
||||
|
||||
// Tag Skeletons
|
||||
export { default as TagCardSkeleton } from './TagCardSkeleton.svelte';
|
||||
export { default as TagGridSkeleton } from './TagGridSkeleton.svelte';
|
||||
|
||||
// Favorite Skeletons
|
||||
export { default as FavoriteCardSkeleton } from './FavoriteCardSkeleton.svelte';
|
||||
export { default as FavoriteGridSkeleton } from './FavoriteGridSkeleton.svelte';
|
||||
|
||||
// Duplicate Skeletons
|
||||
export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte';
|
||||
export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte';
|
||||
|
||||
// App Loading Skeleton
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// Import Preview Skeleton
|
||||
export { default as ImportPreviewSkeleton } from './ImportPreviewSkeleton.svelte';
|
||||
|
||||
// Google Import Skeleton
|
||||
export { default as GoogleImportSkeleton } from './GoogleImportSkeleton.svelte';
|
||||
|
||||
// Contact Detail Skeleton
|
||||
export { default as ContactDetailSkeleton } from './ContactDetailSkeleton.svelte';
|
||||
|
||||
// Contact Notes Skeleton
|
||||
export { default as ContactNotesSkeleton } from './ContactNotesSkeleton.svelte';
|
||||
|
||||
// Network Graph Skeleton
|
||||
export { default as NetworkGraphSkeleton } from './NetworkGraphSkeleton.svelte';
|
||||
|
|
@ -17,10 +17,14 @@
|
|||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Search modal state
|
||||
let searchModalOpen = $state(false);
|
||||
|
||||
// Check if we're on a contact detail route
|
||||
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
|
||||
const showContactModal = $derived(!!contactDetailMatch);
|
||||
|
|
@ -78,9 +82,10 @@
|
|||
{ href: '/', label: 'Kontakte', icon: 'users' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||
{ href: '/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
|
|
@ -92,7 +97,7 @@
|
|||
// Cmd/Ctrl+K to open search (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
// TODO: Open search modal
|
||||
searchModalOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +236,9 @@
|
|||
{#if showContactModal && modalContactId}
|
||||
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
|
||||
{/if}
|
||||
|
||||
<!-- Global Search Modal (Cmd/K) -->
|
||||
<SearchModal bind:open={searchModalOpen} onClose={() => (searchModalOpen = false)} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { contactsApi } from '$lib/api/contacts';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
import { ContactListSkeleton } from '$lib/components/skeletons';
|
||||
import '$lib/i18n';
|
||||
|
||||
let loading = $state(true);
|
||||
|
|
@ -140,9 +141,7 @@
|
|||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<ContactListSkeleton count={6} />
|
||||
{:else if contacts.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
||||
import { exportApi, type ExportFormat } from '$lib/api/export';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { ImportPreviewSkeleton } from '$lib/components/skeletons';
|
||||
import '$lib/i18n';
|
||||
|
||||
type Tab = 'import' | 'export';
|
||||
|
|
@ -277,12 +278,7 @@
|
|||
{#if importStep === 'upload'}
|
||||
<div class="space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Datei wird verarbeitet...</p>
|
||||
</div>
|
||||
<ImportPreviewSkeleton />
|
||||
{:else}
|
||||
<FileUploader onFileSelect={handleFileSelect} />
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { duplicatesApi, type DuplicateGroup } from '$lib/api/duplicates';
|
||||
import MergeModal from '$lib/components/duplicates/MergeModal.svelte';
|
||||
import { DuplicateListSkeleton } from '$lib/components/skeletons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let duplicates = $state<DuplicateGroup[]>([]);
|
||||
|
|
@ -132,11 +133,7 @@
|
|||
|
||||
<!-- Loading state -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<DuplicateListSkeleton count={3} />
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<div class="text-center py-12">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import FavoriteCardView from '$lib/components/favorites/FavoriteCardView.svelte';
|
||||
import FavoriteListView from '$lib/components/favorites/FavoriteListView.svelte';
|
||||
import FavoriteAlphabetView from '$lib/components/favorites/FavoriteAlphabetView.svelte';
|
||||
import { FavoriteGridSkeleton, ContactListSkeleton } from '$lib/components/skeletons';
|
||||
import '$lib/i18n';
|
||||
|
||||
type ViewMode = 'cards' | 'list' | 'alphabet';
|
||||
|
|
@ -271,10 +272,12 @@
|
|||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
<p class="loading-text">Favoriten werden geladen...</p>
|
||||
</div>
|
||||
<!-- Skeleton loading based on view mode -->
|
||||
{#if viewMode === 'cards'}
|
||||
<FavoriteGridSkeleton count={6} />
|
||||
{:else}
|
||||
<ContactListSkeleton count={8} />
|
||||
{/if}
|
||||
{:else if contacts.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { tagsApi } from '$lib/api/contacts';
|
||||
import type { ContactTag } from '$lib/api/contacts';
|
||||
import { TagGridSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let loading = $state(true);
|
||||
let tags = $state<ContactTag[]>([]);
|
||||
|
|
@ -166,9 +167,7 @@
|
|||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<TagGridSkeleton count={6} />
|
||||
{:else if tags.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '$lib/i18n'; // Initialize i18n early
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
// Derived state: app is ready when auth is initialized AND i18n is loaded
|
||||
let appReady = $derived(!loading && !$i18nLoading);
|
||||
|
||||
/**
|
||||
* Global error handler for unhandled promise rejections and API errors
|
||||
*/
|
||||
|
|
@ -81,15 +87,8 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !appReady}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import KanbanColumnComponent from './KanbanColumn.svelte';
|
||||
import AddColumnButton from './AddColumnButton.svelte';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
import { KanbanBoardSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
|
|
@ -116,7 +117,9 @@
|
|||
</script>
|
||||
|
||||
<div class="kanban-board h-full">
|
||||
{#if kanbanStore.error}
|
||||
{#if kanbanStore.loading}
|
||||
<KanbanBoardSkeleton />
|
||||
{:else if kanbanStore.error}
|
||||
<div
|
||||
class="bg-destructive/10 text-destructive p-4 rounded-xl border border-destructive/20 flex items-center gap-3"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppLoadingSkeleton - Full page loading skeleton for initial app load
|
||||
* Shows a minimal skeleton layout while auth is being checked
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
|
||||
<!-- Header placeholder -->
|
||||
<div class="header-skeleton">
|
||||
<SkeletonBox width="140px" height="32px" borderRadius="8px" />
|
||||
<div class="header-nav">
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content placeholder -->
|
||||
<div class="content-skeleton">
|
||||
<!-- Page title -->
|
||||
<div class="title-row">
|
||||
<SkeletonBox width="180px" height="28px" />
|
||||
</div>
|
||||
<SkeletonBox width="220px" height="16px" />
|
||||
|
||||
<!-- Quick add bar -->
|
||||
<div class="quick-add-skeleton">
|
||||
<SkeletonBox width="100%" height="52px" borderRadius="12px" />
|
||||
</div>
|
||||
|
||||
<!-- Task sections -->
|
||||
<div class="sections-skeleton">
|
||||
<!-- Section header -->
|
||||
<div class="section-header">
|
||||
<SkeletonBox width="100px" height="20px" />
|
||||
<SkeletonBox width="28px" height="28px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<!-- Task items -->
|
||||
<div class="task-list">
|
||||
{#each Array(4) as _, i}
|
||||
<div class="task-item" style="opacity: {Math.max(0.3, 1 - i * 0.18)};">
|
||||
<SkeletonBox width="22px" height="22px" borderRadius="6px" />
|
||||
<div class="task-content">
|
||||
<SkeletonBox width="{70 - i * 8}%" height="18px" />
|
||||
<SkeletonBox width="{40 + i * 5}%" height="14px" />
|
||||
</div>
|
||||
<SkeletonBox width="24px" height="24px" borderRadius="4px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-loading-skeleton {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-add-skeleton {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.sections-skeleton {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* KanbanBoardSkeleton - Skeleton for kanban board loading
|
||||
*/
|
||||
|
||||
import KanbanColumnSkeleton from './KanbanColumnSkeleton.svelte';
|
||||
</script>
|
||||
|
||||
<div class="kanban-skeleton" role="status" aria-label="Kanban Board wird geladen...">
|
||||
<div class="columns-container">
|
||||
<KanbanColumnSkeleton taskCount={3} />
|
||||
<KanbanColumnSkeleton taskCount={4} />
|
||||
<KanbanColumnSkeleton taskCount={2} />
|
||||
<div class="add-column-skeleton">
|
||||
<div class="add-column-btn"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-skeleton {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.columns-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.add-column-skeleton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-column-btn {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
height: 48px;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.columns-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.columns-container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* KanbanColumnSkeleton - Skeleton for a single kanban column
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
taskCount?: number;
|
||||
}
|
||||
|
||||
let { taskCount = 3 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="column-skeleton">
|
||||
<!-- Column Header -->
|
||||
<div class="column-header">
|
||||
<div class="header-left">
|
||||
<SkeletonBox width="8px" height="100%" borderRadius="4px" />
|
||||
<SkeletonBox width="100px" height="18px" />
|
||||
<SkeletonBox width="24px" height="20px" borderRadius="10px" />
|
||||
</div>
|
||||
<SkeletonBox width="24px" height="24px" borderRadius="6px" />
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<div class="tasks">
|
||||
{#each Array(taskCount) as _, i}
|
||||
<div class="task-card" style="opacity: {Math.max(0.4, 1 - i * 0.2)};">
|
||||
<SkeletonBox width="75%" height="16px" />
|
||||
<div class="task-meta">
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Add Task Button -->
|
||||
<div class="add-task">
|
||||
<SkeletonBox width="100%" height="36px" borderRadius="8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.column-skeleton {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.add-task {
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* StatisticsSkeleton - Skeleton for statistics page loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="statistics-skeleton" role="status" aria-label="Statistiken werden geladen...">
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="stats-overview">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="stat-card" style="opacity: {Math.max(0.5, 1 - i * 0.08)};">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="10px" />
|
||||
<div class="stat-content">
|
||||
<SkeletonBox width="48px" height="28px" />
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="charts-grid">
|
||||
<!-- Activity Heatmap -->
|
||||
<div class="chart-card heatmap">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="140px" height="20px" />
|
||||
</div>
|
||||
<div class="heatmap-grid">
|
||||
{#each Array(7) as _}
|
||||
<div class="heatmap-row">
|
||||
{#each Array(12) as _}
|
||||
<SkeletonBox width="16px" height="16px" borderRadius="3px" />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="charts-row">
|
||||
<!-- Weekly Trend Chart -->
|
||||
<div class="chart-card trend">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="120px" height="20px" />
|
||||
</div>
|
||||
<div class="trend-bars">
|
||||
{#each Array(7) as _, i}
|
||||
<div class="bar-wrapper">
|
||||
<SkeletonBox width="32px" height="{40 + Math.random() * 60}px" borderRadius="4px" />
|
||||
<SkeletonBox width="24px" height="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Donut Chart -->
|
||||
<div class="chart-card donut">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="100px" height="20px" />
|
||||
</div>
|
||||
<div class="donut-wrapper">
|
||||
<SkeletonBox width="140px" height="140px" borderRadius="50%" />
|
||||
</div>
|
||||
<div class="legend">
|
||||
{#each Array(4) as _}
|
||||
<div class="legend-item">
|
||||
<SkeletonBox width="12px" height="12px" borderRadius="3px" />
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Progress -->
|
||||
<div class="chart-card projects">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="130px" height="20px" />
|
||||
</div>
|
||||
<div class="progress-bars">
|
||||
{#each Array(4) as _, i}
|
||||
<div class="progress-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
|
||||
<div class="progress-header">
|
||||
<SkeletonBox width="{100 + i * 20}px" height="16px" />
|
||||
<SkeletonBox width="40px" height="14px" />
|
||||
</div>
|
||||
<SkeletonBox width="100%" height="8px" borderRadius="4px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="additional-stats">
|
||||
{#each Array(3) as _}
|
||||
<div class="small-stat">
|
||||
<SkeletonBox width="120px" height="12px" />
|
||||
<SkeletonBox width="80px" height="18px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Stats Overview */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Charts Grid */
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Heatmap */
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Charts Row */
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charts-row {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Trend Chart */
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 120px;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.bar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Donut Chart */
|
||||
.donut-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Project Progress */
|
||||
.progress-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Additional Stats */
|
||||
.additional-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.small-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TaskItemSkeleton - Skeleton for a single task item
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
showSubtasks?: boolean;
|
||||
}
|
||||
|
||||
let { showSubtasks = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="task-item-skeleton">
|
||||
<div class="task-main">
|
||||
<SkeletonBox width="22px" height="22px" borderRadius="6px" />
|
||||
<div class="task-content">
|
||||
<SkeletonBox width="65%" height="18px" />
|
||||
<div class="task-meta">
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
<SkeletonBox width="60px" height="20px" borderRadius="10px" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
{#if showSubtasks}
|
||||
<div class="subtasks">
|
||||
{#each Array(2) as _}
|
||||
<div class="subtask-item">
|
||||
<SkeletonBox width="16px" height="16px" borderRadius="4px" />
|
||||
<SkeletonBox width="50%" height="14px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-item-skeleton {
|
||||
padding: 0.875rem 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.task-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtasks {
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TaskListSkeleton - Skeleton for task list with sections
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
import TaskItemSkeleton from './TaskItemSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
sections?: number;
|
||||
tasksPerSection?: number;
|
||||
}
|
||||
|
||||
let { sections = 2, tasksPerSection = 3 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="task-list-skeleton" role="status" aria-label="Aufgaben werden geladen...">
|
||||
{#each Array(sections) as _, sectionIndex}
|
||||
<div class="section" style="opacity: {Math.max(0.5, 1 - sectionIndex * 0.25)};">
|
||||
<!-- Section header -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="6px" />
|
||||
<SkeletonBox width="{100 + sectionIndex * 20}px" height="18px" />
|
||||
<SkeletonBox width="28px" height="22px" borderRadius="11px" />
|
||||
</div>
|
||||
<SkeletonBox width="24px" height="24px" borderRadius="6px" />
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<div class="tasks">
|
||||
{#each Array(tasksPerSection) as _, taskIndex}
|
||||
<div style="opacity: {Math.max(0.4, 1 - taskIndex * 0.2)};">
|
||||
<TaskItemSkeleton />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-list-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
20
apps/todo/apps/web/src/lib/components/skeletons/index.ts
Normal file
20
apps/todo/apps/web/src/lib/components/skeletons/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Todo App Skeleton Components
|
||||
*
|
||||
* App-specific skeleton loaders that match the exact layout of todo components.
|
||||
* Built on top of @manacore/shared-ui skeleton primitives.
|
||||
*/
|
||||
|
||||
// App Loading Skeleton
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// Task List Skeletons
|
||||
export { default as TaskItemSkeleton } from './TaskItemSkeleton.svelte';
|
||||
export { default as TaskListSkeleton } from './TaskListSkeleton.svelte';
|
||||
|
||||
// Statistics Skeletons
|
||||
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
|
||||
|
||||
// Kanban Skeletons
|
||||
export { default as KanbanColumnSkeleton } from './KanbanColumnSkeleton.svelte';
|
||||
export { default as KanbanBoardSkeleton } from './KanbanBoardSkeleton.svelte';
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
|
||||
import { TaskListSkeleton } from '$lib/components/skeletons';
|
||||
import type { Task } from '@todo/shared';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
|
@ -130,11 +131,7 @@
|
|||
<QuickAddTask />
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
<TaskListSkeleton sections={3} tasksPerSection={3} />
|
||||
{:else if tasksStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{tasksStore.error}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import WeeklyTrendChart from '$lib/components/statistics/WeeklyTrendChart.svelte';
|
||||
import PriorityDonutChart from '$lib/components/statistics/PriorityDonutChart.svelte';
|
||||
import ProjectProgressBars from '$lib/components/statistics/ProjectProgressBars.svelte';
|
||||
import { StatisticsSkeleton } from '$lib/components/skeletons';
|
||||
import { BarChart3 } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
|
|
@ -44,10 +45,7 @@
|
|||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Lade Statistiken...</p>
|
||||
</div>
|
||||
<StatisticsSkeleton />
|
||||
{:else}
|
||||
<!-- Quick Stats -->
|
||||
<section class="stats-section">
|
||||
|
|
@ -155,31 +153,6 @@
|
|||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid hsl(var(--muted) / 0.3);
|
||||
border-top-color: #8b5cf6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -20,14 +21,7 @@
|
|||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ export { TagBadge } from './molecules';
|
|||
export { AudioPlayer } from './molecules';
|
||||
|
||||
// Loading/Skeletons
|
||||
export { SkeletonBox, SkeletonText } from './molecules';
|
||||
export {
|
||||
SkeletonBox,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonRow,
|
||||
SkeletonList,
|
||||
SkeletonCard,
|
||||
SkeletonGrid,
|
||||
} from './molecules';
|
||||
|
||||
// Feedback
|
||||
export { EmptyState } from './molecules';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ export { TagBadge } from './tags';
|
|||
export { AudioPlayer } from './media';
|
||||
|
||||
// Loading components
|
||||
export { SkeletonBox, SkeletonText } from './loaders';
|
||||
export {
|
||||
SkeletonBox,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonRow,
|
||||
SkeletonList,
|
||||
SkeletonCard,
|
||||
SkeletonGrid,
|
||||
} from './loaders';
|
||||
|
||||
// Feedback components
|
||||
export { EmptyState } from './feedback';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonAvatar - Circular skeleton for profile pictures/avatars
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonAvatar size="40px" />
|
||||
* <SkeletonAvatar size="64px" />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Size of the avatar (width & height) */
|
||||
size?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = '40px', class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<SkeletonBox width={size} height={size} circle class={className} />
|
||||
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonCard - Configurable card skeleton with avatar, title, body, footer
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonCard showAvatar titleLines={1} bodyLines={2} />
|
||||
* <SkeletonCard showFooter />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
import SkeletonText from './SkeletonText.svelte';
|
||||
import SkeletonAvatar from './SkeletonAvatar.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show avatar/image placeholder */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Number of title lines */
|
||||
titleLines?: number;
|
||||
/** Number of body text lines */
|
||||
bodyLines?: number;
|
||||
/** Show footer section */
|
||||
showFooter?: boolean;
|
||||
/** Opacity for fade effect in lists */
|
||||
opacity?: number;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
showAvatar = false,
|
||||
avatarSize = '48px',
|
||||
titleLines = 1,
|
||||
bodyLines = 2,
|
||||
showFooter = false,
|
||||
opacity = 1,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-card rounded-lg border border-border bg-card p-4 {className}"
|
||||
style="opacity: {opacity};"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
{#if showAvatar}
|
||||
<SkeletonAvatar size={avatarSize} />
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if titleLines > 0}
|
||||
<SkeletonText lines={titleLines} lineHeight="18px" gap="6px" lastLineWidth="60%" />
|
||||
{/if}
|
||||
{#if bodyLines > 0}
|
||||
<div class="mt-2">
|
||||
<SkeletonText lines={bodyLines} lineHeight="14px" gap="6px" lastLineWidth="80%" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showFooter}
|
||||
<div class="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonGrid - Grid of skeleton cards with fade effect
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonGrid count={6} columns={3} />
|
||||
* <SkeletonGrid count={8} columns={4} showAvatar />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonCard from './SkeletonCard.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of cards to show */
|
||||
count?: number;
|
||||
/** Number of columns (CSS grid) */
|
||||
columns?: number;
|
||||
/** Show avatar in cards */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Number of body lines per card */
|
||||
bodyLines?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
/** Gap between cards */
|
||||
gap?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
count = 6,
|
||||
columns = 3,
|
||||
showAvatar = true,
|
||||
avatarSize = '48px',
|
||||
bodyLines = 2,
|
||||
fadeEffect = true,
|
||||
minOpacity = 0.4,
|
||||
gap = '1rem',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-grid grid {className}"
|
||||
style="grid-template-columns: repeat({columns}, minmax(0, 1fr)); gap: {gap};"
|
||||
>
|
||||
{#each Array(count) as _, i}
|
||||
<SkeletonCard {showAvatar} {avatarSize} {bodyLines} opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonList - List of skeleton rows with cascading fade effect
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonList count={5} />
|
||||
* <SkeletonList count={10} showAvatar fadeEffect />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonRow from './SkeletonRow.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of rows to show */
|
||||
count?: number;
|
||||
/** Show avatar in each row */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Apply cascading fade effect (rows fade out towards bottom) */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
/** Gap between rows */
|
||||
gap?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
count = 5,
|
||||
showAvatar = true,
|
||||
avatarSize = '40px',
|
||||
fadeEffect = true,
|
||||
minOpacity = 0.3,
|
||||
gap = '0',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
|
||||
{#each Array(count) as _, i}
|
||||
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonRow - Single row skeleton with avatar and text
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonRow showAvatar />
|
||||
* <SkeletonRow opacity={0.5} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
import SkeletonAvatar from './SkeletonAvatar.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show avatar placeholder */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
/** Show secondary line */
|
||||
showSecondaryLine?: boolean;
|
||||
/** Show right-side content */
|
||||
showRightContent?: boolean;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
showAvatar = true,
|
||||
avatarSize = '40px',
|
||||
opacity = 1,
|
||||
showSecondaryLine = true,
|
||||
showRightContent = true,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-row flex items-center gap-3 border-b border-border px-4 py-3 {className}"
|
||||
style="opacity: {opacity};"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
{#if showAvatar}
|
||||
<SkeletonAvatar size={avatarSize} />
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<SkeletonBox width="45%" height="16px" />
|
||||
{#if showSecondaryLine}
|
||||
<div class="mt-1.5">
|
||||
<SkeletonBox width="65%" height="13px" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showRightContent}
|
||||
<SkeletonBox width="70px" height="13px" />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
/**
|
||||
* Loading state components
|
||||
*
|
||||
* Primitives:
|
||||
* - SkeletonBox: Base rectangular skeleton with shimmer
|
||||
* - SkeletonText: Multi-line text skeleton
|
||||
* - SkeletonAvatar: Circular avatar skeleton
|
||||
*
|
||||
* Composites:
|
||||
* - SkeletonRow: Single list row with avatar + text
|
||||
* - SkeletonList: Multiple rows with fade effect
|
||||
* - SkeletonCard: Card with avatar, title, body, footer
|
||||
* - SkeletonGrid: Grid of cards with fade effect
|
||||
*/
|
||||
|
||||
// Primitives
|
||||
export { default as SkeletonBox } from './SkeletonBox.svelte';
|
||||
export { default as SkeletonText } from './SkeletonText.svelte';
|
||||
export { default as SkeletonAvatar } from './SkeletonAvatar.svelte';
|
||||
|
||||
// Composites
|
||||
export { default as SkeletonRow } from './SkeletonRow.svelte';
|
||||
export { default as SkeletonList } from './SkeletonList.svelte';
|
||||
export { default as SkeletonCard } from './SkeletonCard.svelte';
|
||||
export { default as SkeletonGrid } from './SkeletonGrid.svelte';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue