♻️ refactor: centralize AppLoadingSkeleton in shared-ui

Add configurable AppLoadingSkeleton component to @manacore/shared-ui
with multiple layout presets: list, tasks, sidebar, centered, minimal.

Migrate 3 apps to use the shared component:
- contacts: uses default 'list' layout
- todo: uses 'tasks' layout
- questions: uses 'sidebar' layout

Apps with highly specific layouts (calendar, clock) retain their
local implementations for now.
This commit is contained in:
Till-JS 2026-01-29 15:24:29 +01:00
parent cdac341882
commit 8804ab77a2
13 changed files with 494 additions and 306 deletions

View file

@ -1,117 +0,0 @@
<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

@ -22,8 +22,8 @@ export { default as TagGridSkeleton } from './TagGridSkeleton.svelte';
export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte';
export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte';
// App Loading Skeleton
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// App Loading Skeleton (from shared-ui)
export { AppLoadingSkeleton } from '@manacore/shared-ui';
// Import Preview Skeleton
export { default as ImportPreviewSkeleton } from './ImportPreviewSkeleton.svelte';

View file

@ -1,47 +0,0 @@
<div class="flex min-h-screen animate-pulse">
<!-- Sidebar -->
<aside class="w-64 border-r border-border bg-card">
<!-- Header -->
<div class="flex h-16 items-center border-b border-border px-4">
<div class="h-6 w-28 rounded bg-muted"></div>
</div>
<!-- New Question Button -->
<div class="p-4">
<div class="h-10 w-full rounded-lg bg-muted"></div>
</div>
<!-- Navigation -->
<nav class="space-y-1 px-2">
<div class="h-10 w-full rounded-lg bg-muted"></div>
<div class="my-4 px-3">
<div class="h-3 w-20 rounded bg-muted"></div>
</div>
{#each Array(4) as _}
<div class="h-10 w-full rounded-lg bg-muted"></div>
{/each}
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6">
<div class="mb-6">
<div class="h-8 w-48 rounded bg-muted"></div>
<div class="mt-2 h-4 w-32 rounded bg-muted"></div>
</div>
<div class="mb-6 flex gap-4">
<div class="h-10 flex-1 rounded-lg bg-muted"></div>
<div class="h-10 w-32 rounded-lg bg-muted"></div>
<div class="h-10 w-24 rounded-lg bg-muted"></div>
</div>
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-24 rounded-xl bg-muted"></div>
{/each}
</div>
</main>
</div>

View file

@ -1,3 +1,5 @@
export { default as QuestionSkeleton } from './QuestionSkeleton.svelte';
export { default as QuestionDetailSkeleton } from './QuestionDetailSkeleton.svelte';
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// App Loading Skeleton (from shared-ui with 'sidebar' layout)
export { AppLoadingSkeleton } from '@manacore/shared-ui';

View file

@ -28,7 +28,7 @@
</script>
{#if !appReady}
<AppLoadingSkeleton />
<AppLoadingSkeleton layout="sidebar" />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}

View file

@ -1,130 +0,0 @@
<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

@ -5,8 +5,8 @@
* Built on top of @manacore/shared-ui skeleton primitives.
*/
// App Loading Skeleton
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// App Loading Skeleton (from shared-ui with 'tasks' layout)
export { AppLoadingSkeleton } from '@manacore/shared-ui';
// Task List Skeletons
export { default as TaskItemSkeleton } from './TaskItemSkeleton.svelte';

View file

@ -24,7 +24,7 @@
</script>
{#if !appReady}
<AppLoadingSkeleton />
<AppLoadingSkeleton layout="tasks" listItemCount={4} />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}

View file

@ -27,6 +27,7 @@ Nach eingehender Analyse aller Web-Apps im Monorepo wurden folgende Bereiche auf
6. ✅ **i18n zu 6 Apps hinzugefügt** - todo, skilltree, nutriphi, planta, questions, matrix (jeweils DE + EN)
7. ✅ **AuthGateModal zentralisiert** - `@manacore/shared-auth-ui` für 4 Apps (chat, todo, contacts, calendar)
8. ✅ **Global Error Handler zentralisiert** - `@manacore/shared-ui` für 7 Apps (calendar, chat, clock, contacts, matrix, picture, storage)
9. ✅ **AppLoadingSkeleton zentralisiert** - `@manacore/shared-ui` für 3 Apps (contacts, todo, questions) - Apps mit spezifischen Layouts (calendar, clock) behalten lokale Version
---
@ -216,10 +217,15 @@ Alle Apps nutzen **Mana Core Auth** mit `@manacore/shared-auth`.
- i18n: DE + EN eingebaut
- Optionale Migration-Info für Session-Daten
#### AppLoadingSkeleton
#### AppLoadingSkeleton
- Jede App hat eigene Version
- Könnte mit `@manacore/shared-ui` Skeletons vereinheitlicht werden
> **Status: Erledigt (29.01.2026)**
- ✅ Zentrales `AppLoadingSkeleton` in `@manacore/shared-ui`
- ✅ Migrierte Apps: contacts, todo, questions
- ⏭️ Behalten lokale Version (spezifische Layouts): calendar, clock
- Layout-Presets: `list`, `tasks`, `sidebar`, `centered`, `minimal`
- Slot-Support für benutzerdefinierte Inhalte
#### Global Error Handler ✅
@ -275,7 +281,7 @@ _(Keine offenen Aufgaben mit mittlerer Priorität)_
| Aufgabe | Aufwand | Impact |
|---------|---------|--------|
| App-Skeletons vereinheitlichen | Niedrig | Code-Reduktion |
| ~~App-Skeletons vereinheitlichen~~ | ~~Niedrig~~ | ✅ Erledigt |
| Auth Store Pattern dokumentieren | Niedrig | Onboarding |
---
@ -286,7 +292,7 @@ _(Keine offenen Aufgaben mit mittlerer Priorität)_
2. ~~**i18n** zu fehlenden Apps hinzufügen~~ ✅ Erledigt (6 Apps)
3. ~~**AuthGateModal** in Shared Package extrahieren~~ ✅ Erledigt (4 Apps)
4. ~~**Global Error Handler** extrahieren~~ ✅ Erledigt (7 Apps)
5. **App-Skeletons vereinheitlichen** (niedrige Priorität)
5. ~~**App-Skeletons vereinheitlichen**~~ ✅ Erledigt (3 Apps)
6. **Auth Store Pattern dokumentieren** (niedrige Priorität)
---

View file

@ -34,6 +34,7 @@ export {
SkeletonList,
SkeletonCard,
SkeletonGrid,
AppLoadingSkeleton,
} from './molecules';
// Feedback

View file

@ -36,6 +36,7 @@ export {
SkeletonList,
SkeletonCard,
SkeletonGrid,
AppLoadingSkeleton,
} from './loaders';
// Feedback components

View file

@ -0,0 +1,466 @@
<script lang="ts">
/**
* AppLoadingSkeleton - Full page loading skeleton for app initialization
*
* A flexible loading skeleton that supports different layout presets:
* - list: Default list view with search bar and item rows
* - centered: Centered content (ideal for clock, calendar previews)
* - sidebar: Sidebar + main content layout
* - tasks: Task list with quick-add bar
* - minimal: Just a centered spinner placeholder
*
* @example
* ```svelte
* <AppLoadingSkeleton />
* <AppLoadingSkeleton layout="centered" appLogo="/logo.svg" />
* <AppLoadingSkeleton layout="sidebar" />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import type { Snippet } from 'svelte';
type LayoutPreset = 'list' | 'centered' | 'sidebar' | 'tasks' | 'minimal';
interface Props {
/** Layout preset for the content area */
layout?: LayoutPreset;
/** Show header skeleton (default: true, false for minimal) */
showHeader?: boolean;
/** Number of list items to show (for list/tasks layouts) */
listItemCount?: number;
/** App logo URL (shown in centered layout) */
appLogo?: string;
/** Loading text for screen readers */
loadingLabel?: string;
/** Custom content slot (overrides preset content) */
children?: Snippet;
}
let {
layout = 'list',
showHeader = true,
listItemCount = 5,
appLogo,
loadingLabel = 'App wird geladen...',
children,
}: Props = $props();
// Hide header in minimal layout by default
const displayHeader = $derived(layout === 'minimal' ? false : showHeader);
</script>
<div
class="app-loading-skeleton"
class:minimal-layout={layout === 'minimal'}
class:sidebar-layout={layout === 'sidebar'}
role="status"
aria-label={loadingLabel}
>
{#if displayHeader}
<!-- Header Skeleton -->
<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>
{/if}
<!-- Content Area -->
{#if children}
<!-- Custom content via slot -->
<div class="content-skeleton custom-content">
{@render children()}
</div>
{:else if layout === 'minimal'}
<!-- Minimal: Centered spinner placeholder -->
<div class="minimal-content">
<SkeletonBox width="64px" height="64px" borderRadius="50%" />
<SkeletonBox width="120px" height="16px" borderRadius="4px" />
</div>
{:else if layout === 'centered'}
<!-- Centered: Logo + preview card -->
<div class="centered-content">
{#if appLogo}
<img src={appLogo} alt="" class="app-logo" />
{:else}
<SkeletonBox width="64px" height="64px" borderRadius="16px" />
{/if}
<SkeletonBox width="180px" height="24px" borderRadius="8px" />
<div class="centered-card">
<div class="card-header">
<SkeletonBox width="120px" height="20px" />
<div class="card-actions">
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
</div>
</div>
<div class="card-content">
{#each Array(4) as _, i}
<SkeletonBox
width="100%"
height="32px"
borderRadius="8px"
class="opacity-{Math.max(30, 100 - i * 20)}"
/>
{/each}
</div>
</div>
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
</div>
{:else if layout === 'sidebar'}
<!-- Sidebar: Left sidebar + main content -->
<div class="sidebar-wrapper">
<aside class="sidebar-skeleton">
<div class="sidebar-header">
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
</div>
<nav class="sidebar-nav">
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
<div class="sidebar-divider">
<SkeletonBox width="80px" height="12px" />
</div>
{#each Array(4) as _}
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
{/each}
</nav>
</aside>
<main class="main-skeleton">
<div class="main-header">
<SkeletonBox width="200px" height="32px" />
<SkeletonBox width="120px" height="16px" />
</div>
<div class="main-toolbar">
<SkeletonBox width="100%" height="40px" borderRadius="8px" class="flex-1" />
<SkeletonBox width="120px" height="40px" borderRadius="8px" />
<SkeletonBox width="100px" height="40px" borderRadius="8px" />
</div>
<div class="main-content">
{#each Array(listItemCount) as _, i}
<div style="opacity: {Math.max(0.3, 1 - i * 0.15)}">
<SkeletonBox width="100%" height="80px" borderRadius="12px" />
</div>
{/each}
</div>
</main>
</div>
{:else if layout === 'tasks'}
<!-- Tasks: Quick-add + task sections -->
<div class="content-skeleton tasks-content">
<div class="tasks-header">
<SkeletonBox width="180px" height="28px" />
<SkeletonBox width="220px" height="16px" />
</div>
<div class="quick-add">
<SkeletonBox width="100%" height="52px" borderRadius="12px" />
</div>
<div class="task-section">
<div class="section-header">
<SkeletonBox width="100px" height="20px" />
<SkeletonBox width="28px" height="28px" borderRadius="50%" />
</div>
<div class="task-list">
{#each Array(listItemCount) 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>
{:else}
<!-- List (default): Search + item rows -->
<div class="content-skeleton list-content">
<div class="title-row">
<SkeletonBox width="200px" height="32px" />
<SkeletonBox width="120px" height="40px" borderRadius="8px" />
</div>
<SkeletonBox width="100%" height="48px" borderRadius="12px" />
<div class="list-skeleton">
{#each Array(listItemCount) 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>
{/if}
</div>
<style>
.app-loading-skeleton {
min-height: 100vh;
background: hsl(var(--background));
}
.app-loading-skeleton.sidebar-layout {
display: flex;
flex-direction: column;
}
/* Header */
.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 Areas */
.content-skeleton {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
}
.custom-content {
padding: 0;
max-width: none;
}
/* Minimal Layout */
.minimal-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
}
/* Centered Layout */
.centered-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 80px);
padding: 2rem;
gap: 1.5rem;
}
.app-logo {
width: 64px;
height: 64px;
object-fit: contain;
}
.centered-card {
width: 100%;
max-width: 400px;
background: hsl(var(--card));
border-radius: 16px;
padding: 1.5rem;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-actions {
display: flex;
gap: 0.5rem;
}
.card-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Sidebar Layout */
.sidebar-wrapper {
display: flex;
flex: 1;
min-height: calc(100vh - 65px);
}
.sidebar-skeleton {
width: 16rem;
border-right: 1px solid hsl(var(--border));
background: hsl(var(--card));
padding: 1rem;
}
.sidebar-header {
padding-bottom: 1rem;
border-bottom: 1px solid hsl(var(--border));
margin-bottom: 1rem;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-divider {
padding: 1rem 0.75rem 0.5rem;
}
.main-skeleton {
flex: 1;
padding: 1.5rem;
}
.main-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.main-toolbar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.main-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Tasks Layout */
.tasks-content {
max-width: 48rem;
}
.tasks-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.quick-add {
margin: 1.5rem 0;
}
.task-section {
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;
}
/* List Layout (Default) */
.list-content {
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;
}
/* Responsive */
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
.sidebar-skeleton {
display: none;
}
.sidebar-wrapper {
min-height: calc(100vh - 57px);
}
.centered-content {
padding: 1rem;
}
}
</style>

View file

@ -11,6 +11,9 @@
* - SkeletonList: Multiple rows with fade effect
* - SkeletonCard: Card with avatar, title, body, footer
* - SkeletonGrid: Grid of cards with fade effect
*
* Full Page:
* - AppLoadingSkeleton: Full page loading skeleton with layout presets
*/
// Primitives
@ -23,3 +26,6 @@ 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';
// Full Page
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';