feat(local-first): migrate 9 apps to reactive useLiveQuery reads

Replace manual $state + fetchX() pattern with Dexie liveQuery hooks
across 9 apps. All data reads now auto-update on IndexedDB changes
(local writes, sync, other tabs). Stores reduced to mutation-only.

Apps migrated:
- Zitare: favorites, lists
- Contacts: contacts
- Calendar: calendars, events
- Chat: conversations, templates
- Clock: alarms, timers, worldClocks
- ManaDeck: decks, cards
- Presi: decks, slides
- Context: spaces, documents
- Storage: files, folders

Pattern per app:
1. New queries.ts with useLiveQuery hooks + pure filter helpers
2. Stores slimmed to mutation-only (no $state arrays, no fetch methods)
3. Layout sets context via setContext() for child components
4. Components use getContext() for reactive reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:27:46 +01:00
parent ced7dd7441
commit 30e124e609
87 changed files with 2528 additions and 3136 deletions

View file

@ -0,0 +1,73 @@
/**
* Reactive Queries & Pure Helpers for Presi
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQuery, useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import type { Deck, Slide } from '@presi/shared';
import { deckCollection, slideCollection, type LocalDeck, type LocalSlide } from './local-store';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalDeck (IndexedDB) to shared Deck type. */
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalSlide (IndexedDB) to shared Slide type. */
export function toSlide(local: LocalSlide): Slide {
return {
id: local.id,
deckId: local.deckId,
order: local.order,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All decks, sorted by updatedAt descending. Auto-updates on any change. */
export function useAllDecks() {
return useLiveQueryWithDefault(async () => {
const locals = await deckCollection.getAll();
return locals
.map(toDeck)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [] as Deck[]);
}
/** Slides for a specific deck, sorted by order. Auto-updates on any change. */
export function useDeckSlides(deckId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await slideCollection.getAll({ deckId });
return locals.map(toSlide).sort((a, b) => a.order - b.order);
}, [] as Slide[]);
}
/** Single deck by ID. Has loading state. Auto-updates on any change. */
export function useDeck(id: string) {
return useLiveQuery(async () => {
const local = await deckCollection.get(id);
return local ? toDeck(local) : null;
});
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Find a deck by ID from a list. */
export function findDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}

View file

@ -1,18 +1,17 @@
/**
* Decks Store Local-First with Dexie.js
* Decks Store Mutation-Only
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes (create, update, delete).
*/
import type {
Deck,
Slide,
CreateDeckDto,
UpdateDeckDto,
CreateSlideDto,
UpdateSlideDto,
Deck,
Slide,
} from '@presi/shared';
import {
deckCollection,
@ -20,74 +19,12 @@ import {
type LocalDeck,
type LocalSlide,
} from '$lib/data/local-store';
import { toDeck, toSlide } from '$lib/data/queries';
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let currentSlides = $state<Slide[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
/** Convert LocalDeck (IndexedDB) to shared Deck type. */
function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalSlide (IndexedDB) to shared Slide type. */
function toSlide(local: LocalSlide): Slide {
return {
id: local.id,
deckId: local.deckId,
order: local.order,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
function createDecksStore() {
async function loadDecks() {
isLoading = true;
error = null;
try {
const localDecks = await deckCollection.getAll();
decks = localDecks.map(toDeck);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load decks';
console.error('Failed to load decks:', e);
} finally {
isLoading = false;
}
}
async function loadDeck(id: string) {
isLoading = true;
error = null;
try {
const localDeck = await deckCollection.get(id);
if (localDeck) {
currentDeck = toDeck(localDeck);
} else {
currentDeck = null;
throw new Error('Deck not found');
}
const localSlides = await slideCollection.getAll({ deckId: id });
currentSlides = localSlides.map(toSlide).sort((a, b) => a.order - b.order);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load deck';
console.error('Failed to load deck:', e);
} finally {
isLoading = false;
}
}
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
isLoading = true;
error = null;
@ -100,9 +37,7 @@ function createDecksStore() {
isPublic: false,
};
const inserted = await deckCollection.insert(newLocal);
const deck = toDeck(inserted);
decks = [deck, ...decks];
return deck;
return toDeck(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create deck';
console.error('Failed to create deck:', e);
@ -121,14 +56,7 @@ function createDecksStore() {
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic;
const updated = await deckCollection.update(id, localUpdates);
if (updated) {
const updatedDeck = toDeck(updated);
decks = decks.map((d) => (d.id === id ? updatedDeck : d));
if (currentDeck?.id === id) {
currentDeck = updatedDeck;
}
}
await deckCollection.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update deck';
@ -147,11 +75,6 @@ function createDecksStore() {
}
await deckCollection.delete(id);
decks = decks.filter((d) => d.id !== id);
if (currentDeck?.id === id) {
currentDeck = null;
currentSlides = [];
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete deck';
@ -163,7 +86,8 @@ function createDecksStore() {
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
error = null;
try {
const order = dto.order ?? currentSlides.length + 1;
const existingSlides = await slideCollection.getAll({ deckId });
const order = dto.order ?? existingSlides.length + 1;
const newLocal: LocalSlide = {
id: crypto.randomUUID(),
deckId,
@ -171,9 +95,7 @@ function createDecksStore() {
content: dto.content,
};
const inserted = await slideCollection.insert(newLocal);
const slide = toSlide(inserted);
currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order);
return slide;
return toSlide(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create slide';
console.error('Failed to create slide:', e);
@ -188,12 +110,7 @@ function createDecksStore() {
if (dto.content !== undefined) localUpdates.content = dto.content;
if (dto.order !== undefined) localUpdates.order = dto.order;
const updated = await slideCollection.update(id, localUpdates);
if (updated) {
currentSlides = currentSlides
.map((s) => (s.id === id ? toSlide(updated) : s))
.sort((a, b) => a.order - b.order);
}
await slideCollection.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update slide';
@ -206,7 +123,6 @@ function createDecksStore() {
error = null;
try {
await slideCollection.delete(id);
currentSlides = currentSlides.filter((s) => s.id !== id);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete slide';
@ -218,13 +134,9 @@ function createDecksStore() {
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
error = null;
try {
const orderMap = new Map(slides.map((s) => [s.id, s.order]));
for (const { id, order } of slides) {
await slideCollection.update(id, { order });
}
currentSlides = currentSlides
.map((s) => ({ ...s, order: orderMap.get(s.id) ?? s.order }))
.sort((a, b) => a.order - b.order);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder slides';
@ -233,29 +145,13 @@ function createDecksStore() {
}
}
function clearCurrent() {
currentDeck = null;
currentSlides = [];
}
return {
get decks() {
return decks;
},
get currentDeck() {
return currentDeck;
},
get currentSlides() {
return currentSlides;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
loadDecks,
loadDeck,
createDeck,
updateDeck,
deleteDeck,
@ -263,7 +159,6 @@ function createDecksStore() {
updateSlide,
deleteSlide,
reorderSlides,
clearCurrent,
};
}

View file

@ -19,6 +19,7 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { presiOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -32,6 +33,9 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live query: all decks (reactive, auto-updates on IndexedDB changes)
const allDecks = useAllDecks();
let { children } = $props();
let isCollapsed = $state(false);
@ -137,7 +141,7 @@
// QuickInputBar handlers
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
return decksStore.decks
return (allDecks.value ?? [])
.filter((d) => d.title.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q))
.slice(0, 10)
.map((deck) => ({
@ -162,9 +166,6 @@
tagMutations.startSync(getToken);
}
// Load decks from IndexedDB (guest seed or synced data)
await decksStore.loadDecks();
// Show guest welcome modal on first visit
if (!auth.isAuthenticated && shouldShowGuestWelcome('presi')) {
showGuestWelcome = true;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import { PageHeader, ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import {
@ -20,12 +21,15 @@
let newDeckDescription = $state('');
let isCreating = $state(false);
const allDecks = useAllDecks();
let decks = $derived(allDecks.value ?? []);
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuDeck = $state<(typeof decksStore.decks)[0] | null>(null);
let contextMenuDeck = $state<(typeof decks)[0] | null>(null);
function handleContextMenu(e: MouseEvent, deck: (typeof decksStore.decks)[0]) {
function handleContextMenu(e: MouseEvent, deck: (typeof decks)[0]) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
@ -118,13 +122,7 @@
{/snippet}
</PageHeader>
{#if decksStore.isLoading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else if decksStore.decks.length === 0}
{#if decks.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
@ -143,7 +141,7 @@
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.decks as deck (deck.id)}
{#each decks as deck (deck.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow"

View file

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { decksStore } from '$lib/stores/decks.svelte';
import { useDeck, useDeckSlides } from '$lib/data/queries';
import { auth } from '$lib/stores/auth.svelte';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import { shareApi } from '$lib/api/client';
@ -52,10 +53,11 @@
const deckId = $page.params.id as string;
onMount(() => {
decksStore.loadDeck(deckId);
return () => decksStore.clearCurrent();
});
// Reactive live queries — auto-update on IndexedDB changes
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
function openCreateSlide() {
editingSlide = null;
@ -119,7 +121,7 @@
}
async function moveSlide(slide: Slide, direction: 'up' | 'down') {
const slides = decksStore.currentSlides;
const slides = currentSlides;
const currentIndex = slides.findIndex((s) => s.id === slide.id);
if (currentIndex === -1) return;
@ -215,17 +217,17 @@
</script>
<svelte:head>
<title>{decksStore.currentDeck?.title || 'Loading...'} - Presi</title>
<title>{currentDeck?.title || 'Loading...'} - Presi</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if decksStore.isLoading}
{#if deckQuery.loading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else if decksStore.currentDeck}
{:else if currentDeck}
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
@ -237,11 +239,11 @@
</a>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
{decksStore.currentDeck.title}
{currentDeck.title}
</h1>
{#if decksStore.currentDeck.description}
{#if currentDeck.description}
<p class="text-slate-600 dark:text-slate-400 mt-1">
{decksStore.currentDeck.description}
{currentDeck.description}
</p>
{/if}
</div>
@ -264,7 +266,7 @@
Share
</button>
{/if}
{#if decksStore.currentSlides.length > 0}
{#if currentSlides.length > 0}
<a
href="/present/{deckId}"
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
@ -277,7 +279,7 @@
</div>
<!-- Slides Grid -->
{#if decksStore.currentSlides.length === 0}
{#if currentSlides.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
@ -296,7 +298,7 @@
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.currentSlides as slide, index (slide.id)}
{#each currentSlides as slide, index (slide.id)}
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
>
@ -354,7 +356,7 @@
</button>
<button
onclick={() => moveSlide(slide, 'down')}
disabled={index === decksStore.currentSlides.length - 1}
disabled={index === currentSlides.length - 1}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
aria-label="Move down"
>

View file

@ -25,7 +25,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { useDeck, useDeckSlides } from '$lib/data/queries';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import type { Slide } from '@presi/shared';
import {
@ -29,13 +30,24 @@
const deckId = $page.params.id as string;
// Reactive live queries
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
let maxSlideReached = $state(0);
let hasTrackedStart = $state(false);
// Track presentation start once slides are loaded
$effect(() => {
if (currentSlides.length > 0 && !hasTrackedStart) {
PresiEvents.presentationStarted(currentSlides.length);
hasTrackedStart = true;
}
});
onMount(() => {
decksStore.loadDeck(deckId).then(() => {
PresiEvents.presentationStarted(decksStore.currentSlides.length);
});
// Keyboard navigation
window.addEventListener('keydown', handleKeydown);
window.addEventListener('mousemove', handleMouseMove);
@ -47,7 +59,6 @@
document.removeEventListener('fullscreenchange', handleFullscreenChange);
if (timerInterval) clearInterval(timerInterval);
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
decksStore.clearCurrent();
};
});
@ -95,7 +106,7 @@
}
function nextSlide() {
if (currentSlideIndex < decksStore.currentSlides.length - 1) {
if (currentSlideIndex < currentSlides.length - 1) {
currentSlideIndex++;
if (currentSlideIndex > maxSlideReached) maxSlideReached = currentSlideIndex;
}
@ -139,15 +150,15 @@
goto(`/deck/${deckId}`);
}
const currentSlide = $derived(decksStore.currentSlides[currentSlideIndex]);
const currentSlide = $derived(currentSlides[currentSlideIndex]);
</script>
<svelte:head>
<title>Presenting: {decksStore.currentDeck?.title || 'Loading...'}</title>
<title>Presenting: {currentDeck?.title || 'Loading...'}</title>
</svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if decksStore.isLoading}
{#if deckQuery.loading}
<div class="flex-1 flex items-center justify-center">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
@ -161,9 +172,9 @@
class:pointer-events-none={!showControls}
>
<div class="flex items-center gap-4">
<h1 class="text-lg font-medium truncate max-w-xs">{decksStore.currentDeck?.title}</h1>
<h1 class="text-lg font-medium truncate max-w-xs">{currentDeck?.title}</h1>
<span class="text-sm text-slate-400">
Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length}
Slide {currentSlideIndex + 1} of {currentSlides.length}
</span>
</div>
<button
@ -260,7 +271,7 @@
<!-- Slide Dots -->
<div class="flex items-center gap-2 px-4">
{#each decksStore.currentSlides as _, index}
{#each currentSlides as _, index}
<button
onclick={() => goToSlide(index)}
class="w-2 h-2 rounded-full transition-all"
@ -274,7 +285,7 @@
<button
onclick={nextSlide}
disabled={currentSlideIndex === decksStore.currentSlides.length - 1}
disabled={currentSlideIndex === currentSlides.length - 1}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Next slide"
>

View file

@ -1,13 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { auth } from '$lib/stores/auth.svelte';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { goto } from '$app/navigation';
import { FolderOpen, Stack, Calendar } from '@manacore/shared-icons';
let isLoading = $state(true);
const allDecks = useAllDecks();
let decks = $derived(allDecks.value ?? []);
// Map auth store user to UserProfile
let userProfile = $derived<UserProfile>({
@ -27,11 +27,6 @@
},
};
onMount(async () => {
await decksStore.loadDecks();
isLoading = false;
});
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
@ -62,75 +57,67 @@
<!-- Stats Section -->
<div class="mx-auto max-w-xl px-4 pb-8">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-4 border-primary border-t-transparent"
></div>
<!-- Stats Card -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Statistiken</h2>
<div
class="p-5 rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">
{decks.length}
</div>
<div class="text-sm text-muted-foreground">Präsentationen</div>
</div>
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<Stack class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">-</div>
<div class="text-sm text-muted-foreground">Folien</div>
</div>
</div>
</div>
{:else}
<!-- Stats Card -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Statistiken</h2>
</section>
<!-- Recent Presentations -->
{#if decks.length > 0}
<section>
<h2 class="text-lg font-semibold text-foreground mb-4">Letzte Präsentationen</h2>
<div
class="p-5 rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm"
class="rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm overflow-hidden"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">
{decksStore.decks.length}
</div>
<div class="text-sm text-muted-foreground">Präsentationen</div>
</div>
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<Stack class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">-</div>
<div class="text-sm text-muted-foreground">Folien</div>
</div>
<div class="divide-y divide-black/10 dark:divide-white/10">
{#each decks.slice(0, 5) as deck (deck.id)}
<a
href="/deck/{deck.id}"
class="flex items-center justify-between p-4 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-medium text-foreground">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-muted-foreground truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
</section>
<!-- Recent Presentations -->
{#if decksStore.decks.length > 0}
<section>
<h2 class="text-lg font-semibold text-foreground mb-4">Letzte Präsentationen</h2>
<div
class="rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm overflow-hidden"
>
<div class="divide-y divide-black/10 dark:divide-white/10">
{#each decksStore.decks.slice(0, 5) as deck (deck.id)}
<a
href="/deck/{deck.id}"
class="flex items-center justify-between p-4 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-medium text-foreground">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-muted-foreground truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
</section>
{/if}
{/if}
</div>