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:
Till-JS 2025-12-09 19:41:19 +01:00
parent 99c28242c5
commit b6158a89a6
47 changed files with 2303 additions and 111 deletions

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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';

View file

@ -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>

View file

@ -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">

View file

@ -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} />

View file

@ -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">

View file

@ -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">

View file

@ -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">

View file

@ -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()}

View file

@ -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"
>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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';

View file

@ -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}

View file

@ -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;
}

View file

@ -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()}