mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
ced7dd7441
commit
30e124e609
87 changed files with 2528 additions and 3136 deletions
73
apps/presi/apps/web/src/lib/data/queries.ts
Normal file
73
apps/presi/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue