feat(apps): migrate Presi, Picture, Inventar, NutriPhi, Planta, Storage to local-first

Add IndexedDB data layer (Dexie.js via @manacore/local-store) to 6 more apps,
bringing the total to 12/22 apps with local-first architecture.

For each app:
- Create local-store.ts with typed collections and sync config
- Create guest-seed.ts with onboarding data for guest mode
- Update layout with AuthGate allowGuest={true} + handleAuthReady()
- Add GuestWelcomeModal for first-visit experience
- Add @manacore/local-store dependency

App-specific changes:
- Presi: Rewrite decks store from API to IndexedDB, conditional share button
- Picture: Rewrite gallery + boards pages to read from IndexedDB
- Inventar: Replace manual auth $effect with AuthGate, keep localStorage stores
- NutriPhi: Add onReady handler to existing AuthGate
- Planta: Add allowGuest + sync init to existing AuthGate
- Storage: Add local store init to existing handleAuthReady

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 17:35:38 +01:00
parent 8f40de2966
commit ce51fd5fe2
31 changed files with 1623 additions and 211 deletions

View file

@ -42,6 +42,7 @@
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-ui": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tags": "workspace:*",

View file

@ -0,0 +1,62 @@
/**
* Guest seed data for the Presi app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They serve as onboarding content that teaches the user how the app works.
*/
import type { LocalDeck, LocalSlide } from './local-store';
const ONBOARDING_DECK_ID = 'onboarding-deck';
export const guestDecks: LocalDeck[] = [
{
id: ONBOARDING_DECK_ID,
title: 'Willkommen bei Presi',
description: 'Eine kurze Einführung in die Präsentations-App.',
isPublic: false,
},
];
export const guestSlides: LocalSlide[] = [
{
id: 'slide-1',
deckId: ONBOARDING_DECK_ID,
order: 1,
content: {
type: 'title',
title: 'Willkommen bei Presi!',
subtitle: 'Erstelle Präsentationen direkt im Browser.',
},
},
{
id: 'slide-2',
deckId: ONBOARDING_DECK_ID,
order: 2,
content: {
type: 'content',
title: 'So funktioniert es',
bulletPoints: [
'Erstelle Decks mit dem + Button',
'Füge Slides mit Text, Bildern und Aufzählungen hinzu',
'Starte die Präsentation mit dem Play-Button',
'Melde dich an, um zu synchronisieren',
],
},
},
{
id: 'slide-3',
deckId: ONBOARDING_DECK_ID,
order: 3,
content: {
type: 'content',
title: 'Tastaturkürzel',
bulletPoints: [
'Pfeiltasten / A+D — Slides navigieren',
'F — Vollbild',
'ESC — Präsentation beenden',
'T — Themes öffnen',
],
},
},
];

View file

@ -0,0 +1,58 @@
/**
* Presi Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Presi data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestDecks, guestSlides } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalDeck extends BaseRecord {
title: string;
description?: string | null;
themeId?: string | null;
isPublic: boolean;
}
export interface LocalSlide extends BaseRecord {
deckId: string;
order: number;
content: {
type: 'title' | 'content' | 'image' | 'split';
title?: string;
subtitle?: string;
body?: string;
imageUrl?: string;
bulletPoints?: string[];
};
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const presiStore = createLocalStore({
appId: 'presi',
collections: [
{
name: 'decks',
indexes: ['isPublic'],
guestSeed: guestDecks,
},
{
name: 'slides',
indexes: ['deckId', 'order', '[deckId+order]'],
guestSeed: guestSlides,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const deckCollection = presiStore.collection<LocalDeck>('decks');
export const slideCollection = presiStore.collection<LocalSlide>('slides');

View file

@ -1,4 +1,11 @@
import { decksApi, slidesApi } from '$lib/api/client';
/**
* Decks Store Local-First with Dexie.js
*
* 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.
*/
import type {
Deck,
Slide,
@ -7,19 +14,51 @@ import type {
CreateSlideDto,
UpdateSlideDto,
} from '@presi/shared';
import {
deckCollection,
slideCollection,
type LocalDeck,
type LocalSlide,
} from '$lib/data/local-store';
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() {
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let currentSlides = $state<Slide[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
async function loadDecks() {
isLoading = true;
error = null;
try {
decks = await decksApi.getAll();
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);
@ -32,9 +71,15 @@ function createDecksStore() {
isLoading = true;
error = null;
try {
const data = await decksApi.getOne(id);
currentDeck = data.deck;
currentSlides = data.slides.sort((a, b) => a.order - b.order);
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);
@ -47,7 +92,15 @@ function createDecksStore() {
isLoading = true;
error = null;
try {
const deck = await decksApi.create(dto);
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
title: dto.title,
description: dto.description || null,
themeId: dto.themeId || null,
isPublic: false,
};
const inserted = await deckCollection.insert(newLocal);
const deck = toDeck(inserted);
decks = [deck, ...decks];
return deck;
} catch (e) {
@ -62,10 +115,19 @@ function createDecksStore() {
async function updateDeck(id: string, dto: UpdateDeckDto): Promise<boolean> {
error = null;
try {
const updated = await decksApi.update(id, dto);
decks = decks.map((d) => (d.id === id ? updated : d));
if (currentDeck?.id === id) {
currentDeck = updated;
const localUpdates: Partial<LocalDeck> = {};
if (dto.title !== undefined) localUpdates.title = dto.title;
if (dto.description !== undefined) localUpdates.description = dto.description;
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;
}
}
return true;
} catch (e) {
@ -78,7 +140,13 @@ function createDecksStore() {
async function deleteDeck(id: string): Promise<boolean> {
error = null;
try {
await decksApi.delete(id);
// Delete all slides belonging to this deck
const slides = await slideCollection.getAll({ deckId: id });
for (const slide of slides) {
await slideCollection.delete(slide.id);
}
await deckCollection.delete(id);
decks = decks.filter((d) => d.id !== id);
if (currentDeck?.id === id) {
currentDeck = null;
@ -95,7 +163,15 @@ function createDecksStore() {
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
error = null;
try {
const slide = await slidesApi.create(deckId, dto);
const order = dto.order ?? currentSlides.length + 1;
const newLocal: LocalSlide = {
id: crypto.randomUUID(),
deckId,
order,
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;
} catch (e) {
@ -108,8 +184,16 @@ function createDecksStore() {
async function updateSlide(id: string, dto: UpdateSlideDto): Promise<boolean> {
error = null;
try {
const updated = await slidesApi.update(id, dto);
currentSlides = currentSlides.map((s) => (s.id === id ? updated : s));
const localUpdates: Partial<LocalSlide> = {};
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);
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update slide';
@ -121,7 +205,7 @@ function createDecksStore() {
async function deleteSlide(id: string): Promise<boolean> {
error = null;
try {
await slidesApi.delete(id);
await slideCollection.delete(id);
currentSlides = currentSlides.filter((s) => s.id !== id);
return true;
} catch (e) {
@ -134,9 +218,10 @@ function createDecksStore() {
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
error = null;
try {
await slidesApi.reorder({ slides });
// Update local state
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);

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
@ -17,6 +16,9 @@
import { decksStore } from '$lib/stores/decks.svelte';
import { presiOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { presiStore } from '$lib/data/local-store';
// App switcher items
const appItems = getPillAppItems('presi');
@ -25,6 +27,12 @@
let isCollapsed = $state(false);
// Guest welcome modal state
let showGuestWelcome = $state(false);
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(auth.isAuthenticated ? auth.user?.email || 'Menü' : '');
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
@ -56,9 +64,6 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(auth.user?.email);
// TagStrip visibility
let isTagStripVisible = $state(false);
function handleTagStripToggle() {
@ -137,16 +142,28 @@
goto(`/deck/${item.id}`);
}
onMount(async () => {
// Redirect to login if not authenticated
if (!auth.isAuthenticated) {
goto('/login');
return;
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await presiStore.initialize();
// If authenticated, start syncing to server
if (auth.isAuthenticated) {
presiStore.startSync(() => auth.getValidToken());
}
// Load user settings and tags
await userSettings.load();
await tagStore.fetchTags();
// 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;
}
if (auth.isAuthenticated) {
// Load user settings and tags (require auth)
await userSettings.load();
await tagStore.fetchTags();
}
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
@ -160,7 +177,7 @@
isCollapsed = true;
collapsedStore.set(true);
}
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -171,81 +188,98 @@
{@render children()}
</main>
{:else}
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Presi"
homeRoute="/"
onToggleTheme={handleToggleTheme}
isDark={theme.isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
primaryColor="#64748b"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
/>
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagStore.tags.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
<AuthGate authStore={auth} {goto} allowGuest={true} onReady={handleAuthReady}>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Presi"
homeRoute="/"
onToggleTheme={handleToggleTheme}
isDark={theme.isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={auth.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#64748b"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
/>
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagStore.tags.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Präsentation suchen..."
emptyText="Keine Decks gefunden"
searchingText="Suche..."
locale={$locale || 'de'}
appIcon="search"
bottomOffset="70px"
/>
<!-- Main Content -->
<main class="main-content">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<!-- Onboarding Modal -->
{#if presiOnboarding.shouldShow}
<MiniOnboardingModal store={presiOnboarding} appName="Presi" appEmoji="📊" />
{/if}
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Präsentation suchen..."
emptyText="Keine Decks gefunden"
searchingText="Suche..."
locale={$locale || 'de'}
appIcon="search"
bottomOffset="70px"
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="presi"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
<!-- Main Content -->
<main class="main-content">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<!-- Onboarding Modal -->
{#if presiOnboarding.shouldShow}
<MiniOnboardingModal store={presiOnboarding} appName="Presi" appEmoji="📊" />
{/if}
{#if auth.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
{/if}
<style>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { PresiEvents } from '@manacore/shared-utils/analytics';
@ -60,10 +59,6 @@
];
}
onMount(() => {
decksStore.loadDecks();
});
async function handleCreateDeck(e: SubmitEvent) {
e.preventDefault();
if (!newDeckTitle.trim()) return;

View file

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { decksStore } from '$lib/stores/decks.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import { shareApi } from '$lib/api/client';
import type { ShareLink } from '$lib/api/client';
@ -254,13 +255,15 @@
<Plus class="w-5 h-5" />
Add Slide
</button>
<button
onclick={openShareModal}
class="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors"
>
<ShareNetwork class="w-5 h-5" />
Share
</button>
{#if auth.isAuthenticated}
<button
onclick={openShareModal}
class="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors"
>
<ShareNetwork class="w-5 h-5" />
Share
</button>
{/if}
{#if decksStore.currentSlides.length > 0}
<a
href="/present/{deckId}"