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

@ -313,7 +313,7 @@ apps/todo/apps/backend/ # Oder: services/todo/ (umstrukturieren?)
---
## Phase 3: Rollout auf alle Apps (4-6 Wochen) — 6/8 DONE 2026-03-27
## Phase 3: Rollout auf alle Apps (4-6 Wochen) — 8/8 DONE 2026-03-27
Reihenfolge nach Komplexität:

View file

@ -532,8 +532,12 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
| Clock | alarms, timers, worldClocks | Done |
| Contacts | contacts | Done |
| ManaDeck | decks, cards | Done |
| Picture | — | Not yet |
| Presi | — | Not yet |
| Picture | images, boards, boardItems, tags, imageTags | Done |
| Presi | decks, slides | Done |
| Inventar | collections, items, locations, categories | Done |
| NutriPhi | meals, goals, favorites | Done |
| Planta | plants, plantPhotos, wateringSchedules, wateringLogs | Done |
| Storage | files, folders, tags, fileTags | Done |
### Dev Commands (Local-First Stack)

View file

@ -33,6 +33,8 @@
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-landing-ui": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -0,0 +1,100 @@
/**
* Guest seed data for the Inventar app.
*
* Provides a demo collection with sample items and locations.
*/
import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './local-store';
const DEMO_COLLECTION_ID = 'demo-electronics';
const DEMO_LOCATION_ID = 'demo-home';
const DEMO_CATEGORY_ID = 'demo-tech';
export const guestCollections: LocalCollection[] = [
{
id: DEMO_COLLECTION_ID,
name: 'Meine Elektronik',
description: 'Beispiel-Sammlung zum Kennenlernen von Inventar.',
icon: '💻',
color: '#3b82f6',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{ id: 'serial', name: 'Seriennummer', type: 'text', order: 2 },
],
},
order: 0,
itemCount: 2,
},
];
export const guestItems: LocalItem[] = [
{
id: 'item-laptop',
collectionId: DEMO_COLLECTION_ID,
locationId: DEMO_LOCATION_ID,
categoryId: DEMO_CATEGORY_ID,
name: 'MacBook Pro',
description: 'Arbeits-Laptop',
status: 'owned',
quantity: 1,
fieldValues: { brand: 'Apple', model: 'MacBook Pro 14"', serial: 'ABC123' },
photos: [],
notes: [],
tags: ['arbeit'],
order: 0,
},
{
id: 'item-headphones',
collectionId: DEMO_COLLECTION_ID,
locationId: DEMO_LOCATION_ID,
name: 'Kopfhörer',
description: 'Noise-Cancelling Kopfhörer',
status: 'owned',
quantity: 1,
fieldValues: { brand: 'Sony', model: 'WH-1000XM5' },
photos: [],
notes: [],
tags: ['audio'],
order: 1,
},
];
export const guestLocations: LocalLocation[] = [
{
id: DEMO_LOCATION_ID,
name: 'Zuhause',
description: 'Mein Zuhause',
icon: '🏠',
path: 'Zuhause',
depth: 0,
order: 0,
},
{
id: 'demo-office',
parentId: DEMO_LOCATION_ID,
name: 'Büro',
icon: '🖥️',
path: 'Zuhause/Büro',
depth: 1,
order: 0,
},
];
export const guestCategories: LocalCategory[] = [
{
id: DEMO_CATEGORY_ID,
name: 'Technik',
icon: '⚡',
color: '#6366f1',
order: 0,
},
{
id: 'demo-audio',
name: 'Audio',
icon: '🎧',
color: '#ec4899',
order: 1,
},
];

View file

@ -0,0 +1,120 @@
/**
* Inventar Local-First Data Layer
*
* Migrates from localStorage to IndexedDB (Dexie.js) with sync support.
* Collections, items, locations, and categories are stored locally.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestCollections, guestItems, guestLocations, guestCategories } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalCollection extends BaseRecord {
name: string;
description?: string | null;
icon?: string | null;
color?: string | null;
schema: {
fields: Array<{
id: string;
name: string;
type: string;
required?: boolean;
defaultValue?: unknown;
options?: string[];
currencyCode?: string;
placeholder?: string;
order: number;
}>;
};
templateId?: string | null;
order: number;
itemCount: number;
}
export interface LocalItem extends BaseRecord {
collectionId: string;
locationId?: string | null;
categoryId?: string | null;
name: string;
description?: string | null;
status: 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed';
quantity: number;
fieldValues: Record<string, unknown>;
purchaseData?: {
price?: number;
currency?: string;
date?: string;
retailer?: string;
warrantyExpiry?: string;
} | null;
photos: Array<{ id: string; url: string; caption?: string; order: number }>;
notes: Array<{ id: string; content: string; createdAt: string }>;
tags: string[];
order: number;
}
export interface LocalLocation extends BaseRecord {
parentId?: string | null;
name: string;
description?: string | null;
icon?: string | null;
path: string;
depth: number;
order: number;
}
export interface LocalCategory extends BaseRecord {
parentId?: string | null;
name: string;
icon?: string | null;
color?: string | null;
order: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const inventarStore = createLocalStore({
appId: 'inventar',
collections: [
{
name: 'collections',
indexes: ['order', 'templateId'],
guestSeed: guestCollections,
},
{
name: 'items',
indexes: [
'collectionId',
'locationId',
'categoryId',
'status',
'name',
'[collectionId+order]',
],
guestSeed: guestItems,
},
{
name: 'locations',
indexes: ['parentId', 'path', 'depth', 'order'],
guestSeed: guestLocations,
},
{
name: 'categories',
indexes: ['parentId', 'order'],
guestSeed: guestCategories,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const collectionCollection = inventarStore.collection<LocalCollection>('collections');
export const itemCollection = inventarStore.collection<LocalItem>('items');
export const locationCollection = inventarStore.collection<LocalLocation>('locations');
export const categoryCollection = inventarStore.collection<LocalCategory>('categories');

View file

@ -12,30 +12,38 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import { PillNavigation } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { inventarStore } from '$lib/data/local-store';
let { children } = $props();
let showNav = $state(true);
let initialized = $state(false);
let showGuestWelcome = $state(false);
// Initialize stores
$effect(() => {
if (authStore.initialized && !initialized) {
collectionsStore.initialize();
itemsStore.initialize();
locationsStore.initialize();
categoriesStore.initialize();
viewStore.initialize();
initialized = true;
}
});
async function handleAuthReady() {
// Initialize local-first database
await inventarStore.initialize();
// Auth gate
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
// If authenticated, start syncing
if (authStore.isAuthenticated) {
inventarStore.startSync(() => authStore.getValidToken());
}
});
// Initialize legacy localStorage stores (will be migrated to IndexedDB later)
collectionsStore.initialize();
itemsStore.initialize();
locationsStore.initialize();
categoriesStore.initialize();
viewStore.initialize();
initialized = true;
// Show guest welcome on first visit
if (!authStore.isAuthenticated && shouldShowGuestWelcome('inventar')) {
showGuestWelcome = true;
}
}
const navItems = [
{ href: '/', label: 'Sammlungen', icon: 'archive' },
@ -54,17 +62,7 @@
}
</script>
{#if !authStore.initialized}
<div class="flex min-h-screen items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
></div>
</div>
{:else if !authStore.isAuthenticated}
<div class="flex min-h-screen items-center justify-center">
<p class="text-[hsl(var(--muted-foreground))]">Weiterleitung...</p>
</div>
{:else}
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="flex min-h-screen flex-col">
<!-- Top Navigation -->
{#if showNav}
@ -170,4 +168,14 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
{/if}
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="inventar"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
</AuthGate>

View file

@ -50,6 +50,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-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",

View file

@ -0,0 +1,55 @@
/**
* Guest seed data for the NutriPhi app.
*
* Provides demo meals and default goals for the onboarding experience.
*/
import type { LocalMeal, LocalGoal } from './local-store';
const today = new Date().toISOString().split('T')[0];
export const guestMeals: LocalMeal[] = [
{
id: 'meal-breakfast',
date: today,
mealType: 'breakfast',
inputType: 'text',
description: 'Haferflocken mit Banane und Honig',
confidence: 0.9,
nutrition: {
calories: 380,
protein: 10,
carbohydrates: 68,
fat: 8,
fiber: 6,
sugar: 24,
},
},
{
id: 'meal-lunch',
date: today,
mealType: 'lunch',
inputType: 'text',
description: 'Vollkorn-Sandwich mit Avocado und Ei',
confidence: 0.85,
nutrition: {
calories: 520,
protein: 22,
carbohydrates: 45,
fat: 28,
fiber: 8,
sugar: 4,
},
},
];
export const guestGoals: LocalGoal[] = [
{
id: 'default-goals',
dailyCalories: 2000,
dailyProtein: 60,
dailyCarbs: 250,
dailyFat: 65,
dailyFiber: 30,
},
];

View file

@ -0,0 +1,83 @@
/**
* NutriPhi Local-First Data Layer
*
* Meals, nutrition, goals, and favorites stored locally.
* AI analysis and recommendations remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestMeals, guestGoals } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalMeal extends BaseRecord {
date: string;
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
inputType: 'photo' | 'text';
description: string;
portionSize?: string | null;
confidence: number;
nutrition?: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
} | null;
}
export interface LocalGoal extends BaseRecord {
dailyCalories: number;
dailyProtein?: number | null;
dailyCarbs?: number | null;
dailyFat?: number | null;
dailyFiber?: number | null;
}
export interface LocalFavorite extends BaseRecord {
name: string;
description: string;
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
nutrition: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
};
usageCount: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const nutriphiStore = createLocalStore({
appId: 'nutriphi',
collections: [
{
name: 'meals',
indexes: ['date', 'mealType', '[date+mealType]'],
guestSeed: guestMeals,
},
{
name: 'goals',
indexes: [],
guestSeed: guestGoals,
},
{
name: 'favorites',
indexes: ['mealType', 'usageCount'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const mealCollection = nutriphiStore.collection<LocalMeal>('meals');
export const goalCollection = nutriphiStore.collection<LocalGoal>('goals');
export const favoriteCollection = nutriphiStore.collection<LocalFavorite>('favorites');

View file

@ -8,9 +8,13 @@
import { authStore } from '$lib/stores/auth.svelte';
import { mealsStore } from '$lib/stores/meals.svelte';
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { nutriphiStore } from '$lib/data/local-store';
let { children } = $props();
let showGuestWelcome = $state(false);
// QuickInputBar handlers - search recent meals
async function handleSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
@ -38,6 +42,16 @@
};
}
async function handleAuthReady() {
await nutriphiStore.initialize();
if (authStore.isAuthenticated) {
nutriphiStore.startSync(() => authStore.getValidToken());
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('nutriphi')) {
showGuestWelcome = true;
}
}
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
const parsed = parseMealInput(query);
@ -60,7 +74,7 @@
{/if}
</svelte:head>
<AuthGate {authStore} {goto} allowGuest={true}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
{#if $i18nLoading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div
@ -88,4 +102,13 @@
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<GuestWelcomeModal
appId="nutriphi"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
</AuthGate>

View file

@ -28,6 +28,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,131 @@
/**
* Guest seed data for the Picture app.
*
* Provides a demo board with text items to showcase the moodboard feature.
* No actual images are included since generation requires authentication.
*/
import type { LocalBoard, LocalBoardItem, LocalTag } from './local-store';
const DEMO_BOARD_ID = 'demo-moodboard';
export const guestBoards: LocalBoard[] = [
{
id: DEMO_BOARD_ID,
name: 'Willkommen bei Picture',
description: 'Dein erstes Moodboard — erstelle eigene Bilder mit KI!',
canvasWidth: 2000,
canvasHeight: 1500,
backgroundColor: '#1e1e2e',
isPublic: false,
},
];
export const guestBoardItems: LocalBoardItem[] = [
{
id: 'text-welcome',
boardId: DEMO_BOARD_ID,
itemType: 'text',
textContent: 'Willkommen bei Picture!',
fontSize: 48,
color: '#ffffff',
positionX: 600,
positionY: 200,
scaleX: 1,
scaleY: 1,
rotation: 0,
zIndex: 10,
opacity: 1,
width: 800,
height: null,
properties: {
fontFamily: 'Arial',
fontWeight: 'bold',
textAlign: 'center',
},
},
{
id: 'text-hint-1',
boardId: DEMO_BOARD_ID,
itemType: 'text',
textContent: 'Erstelle KI-Bilder mit einem Prompt',
fontSize: 24,
color: '#a0a0c0',
positionX: 650,
positionY: 400,
scaleX: 1,
scaleY: 1,
rotation: 0,
zIndex: 9,
opacity: 1,
width: 700,
height: null,
properties: {
fontFamily: 'Arial',
fontWeight: 'normal',
textAlign: 'center',
},
},
{
id: 'text-hint-2',
boardId: DEMO_BOARD_ID,
itemType: 'text',
textContent: 'Organisiere Bilder in Moodboards',
fontSize: 24,
color: '#a0a0c0',
positionX: 650,
positionY: 500,
scaleX: 1,
scaleY: 1,
rotation: 0,
zIndex: 8,
opacity: 1,
width: 700,
height: null,
properties: {
fontFamily: 'Arial',
fontWeight: 'normal',
textAlign: 'center',
},
},
{
id: 'text-hint-3',
boardId: DEMO_BOARD_ID,
itemType: 'text',
textContent: 'Melde dich an, um loszulegen →',
fontSize: 20,
color: '#6366f1',
positionX: 700,
positionY: 650,
scaleX: 1,
scaleY: 1,
rotation: 0,
zIndex: 7,
opacity: 1,
width: 600,
height: null,
properties: {
fontFamily: 'Arial',
fontWeight: 'bold',
textAlign: 'center',
},
},
];
export const guestTags: LocalTag[] = [
{
id: 'tag-landscape',
name: 'Landschaft',
color: '#22c55e',
},
{
id: 'tag-portrait',
name: 'Portrait',
color: '#3b82f6',
},
{
id: 'tag-abstract',
name: 'Abstrakt',
color: '#a855f7',
},
];

View file

@ -0,0 +1,116 @@
/**
* Picture Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* Images metadata, boards, board items, and tags are stored locally.
* Image generation, upload, and explore remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestBoards, guestBoardItems, guestTags } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalImage extends BaseRecord {
prompt: string;
negativePrompt?: string | null;
model?: string | null;
style?: string | null;
publicUrl?: string | null;
storagePath: string;
filename: string;
format?: string | null;
width?: number | null;
height?: number | null;
fileSize?: number | null;
blurhash?: string | null;
isPublic: boolean;
isFavorite: boolean;
downloadCount: number;
rating?: number | null;
archivedAt?: string | null;
generationId?: string | null;
sourceImageId?: string | null;
}
export interface LocalBoard extends BaseRecord {
name: string;
description?: string | null;
thumbnailUrl?: string | null;
canvasWidth: number;
canvasHeight: number;
backgroundColor: string;
isPublic: boolean;
}
export interface LocalBoardItem extends BaseRecord {
boardId: string;
itemType: 'image' | 'text';
imageId?: string | null;
textContent?: string | null;
fontSize?: number | null;
color?: string | null;
positionX: number;
positionY: number;
scaleX: number;
scaleY: number;
rotation: number;
zIndex: number;
opacity: number;
width?: number | null;
height?: number | null;
properties: Record<string, unknown>;
}
export interface LocalTag extends BaseRecord {
name: string;
color?: string | null;
}
export interface LocalImageTag extends BaseRecord {
imageId: string;
tagId: string;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const pictureStore = createLocalStore({
appId: 'picture',
collections: [
{
name: 'images',
indexes: ['isFavorite', 'isPublic', 'archivedAt', 'prompt'],
},
{
name: 'boards',
indexes: ['isPublic'],
guestSeed: guestBoards,
},
{
name: 'boardItems',
indexes: ['boardId', 'itemType', 'zIndex', '[boardId+zIndex]'],
guestSeed: guestBoardItems,
},
{
name: 'tags',
indexes: ['name'],
guestSeed: guestTags,
},
{
name: 'imageTags',
indexes: ['imageId', 'tagId', '[imageId+tagId]'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const imageCollection = pictureStore.collection<LocalImage>('images');
export const boardCollection = pictureStore.collection<LocalBoard>('boards');
export const boardItemCollection = pictureStore.collection<LocalBoardItem>('boardItems');
export const tagCollection = pictureStore.collection<LocalTag>('tags');
export const imageTagCollection = pictureStore.collection<LocalImageTag>('imageTags');

View file

@ -22,7 +22,9 @@
import { tagStore } from '$lib/stores/tags';
import { pictureOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { pictureStore } from '$lib/data/local-store';
import { viewMode, setViewMode } from '$lib/stores/view';
import type { ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
@ -35,6 +37,9 @@
// PillNav state
let isCollapsed = $state(false);
// Guest welcome modal state
let showGuestWelcome = $state(false);
// Load persisted nav state
$effect(() => {
if (browser) {
@ -82,7 +87,22 @@
}
async function handleAuthReady() {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
// Initialize local-first database (opens IndexedDB, seeds guest data)
await pictureStore.initialize();
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
pictureStore.startSync(() => authStore.getValidToken());
}
// Show guest welcome modal on first visit
if (!authStore.isAuthenticated && shouldShowGuestWelcome('picture')) {
showGuestWelcome = true;
}
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
}
// Redirect to start page if on /app and a custom start page is set
const currentPath = window.location.pathname;
@ -169,8 +189,8 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email);
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Elements (divider + view mode tabs)
let elements: PillNavElement[] = $derived([
@ -252,7 +272,7 @@
<svelte:window on:keydown={handleKeyDown} />
<AuthGate {authStore} {goto} loginHref="/auth/login" onReady={handleAuthReady}>
<AuthGate {authStore} {goto} loginHref="/auth/login" allowGuest={true} onReady={handleAuthReady}>
<div class="min-h-screen" style="background-color: hsl(var(--color-background));">
<!-- PillNavigation (conditionally visible) -->
{#if $isUIVisible}
@ -276,7 +296,8 @@
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
showLogout={authStore.isAuthenticated}
loginHref="/auth/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
@ -321,7 +342,19 @@
<MiniOnboardingModal store={pictureOnboarding} appName="Picture" appEmoji="🎨" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/auth/login" />
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="picture"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/auth/login')}
onRegister={() => goto('/auth/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/auth/login" />
{/if}
</AuthGate>
<style>

View file

@ -13,10 +13,13 @@
addBoard,
removeBoardFromList,
} from '$lib/stores/boards';
import { getBoards, deleteBoard, duplicateBoard } from '$lib/api/boards';
import type { BoardWithCount } from '$lib/api/boards';
import { boardCollection, boardItemCollection, type LocalBoard } from '$lib/data/local-store';
import { PageHeader, Button, Modal, toastStore } from '@manacore/shared-ui';
import { Plus, SquaresFour, Image, Trash } from '@manacore/shared-icons';
const PAGE_SIZE = 20;
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
@ -30,6 +33,25 @@
let showDeleteModal = $state(false);
let deletingBoard = $state<string | null>(null);
/** Convert LocalBoard to BoardWithCount for existing components. */
async function toBoardWithCount(local: LocalBoard): Promise<BoardWithCount> {
const items = await boardItemCollection.getAll({ boardId: local.id });
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? undefined,
thumbnailUrl: local.thumbnailUrl ?? undefined,
canvasWidth: local.canvasWidth,
canvasHeight: local.canvasHeight,
backgroundColor: local.backgroundColor,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
itemCount: items.length,
};
}
onMount(() => {
resetBoardsState();
loadInitialBoards();
@ -57,14 +79,18 @@
});
async function loadInitialBoards() {
if (!authStore.user) return;
isLoadingBoards.set(true);
try {
const data = await getBoards({ page: 1 });
const localBoards = await boardCollection.getAll();
// Sort by updatedAt descending
localBoards.sort(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
const page1 = localBoards.slice(0, PAGE_SIZE);
const data = await Promise.all(page1.map(toBoardWithCount));
boards.set(data);
currentBoardsPage.set(1);
hasBoardsMore.set(data.length === 20);
hasBoardsMore.set(localBoards.length > PAGE_SIZE);
} catch (error) {
console.error('Error loading boards:', error);
toastStore.show('Fehler beim Laden der Boards', 'error');
@ -74,17 +100,24 @@
}
async function loadMoreBoards() {
if (!authStore.user || !$hasBoardsMore || $isLoadingBoards || loadingMore) return;
if (!$hasBoardsMore || $isLoadingBoards || loadingMore) return;
loadingMore = true;
const nextPage = $currentBoardsPage + 1;
try {
const newBoards = await getBoards({ page: nextPage });
if (newBoards.length > 0) {
boards.update((current) => [...current, ...newBoards]);
const localBoards = await boardCollection.getAll();
localBoards.sort(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
const start = (nextPage - 1) * PAGE_SIZE;
const pageBoards = localBoards.slice(start, start + PAGE_SIZE);
if (pageBoards.length > 0) {
const data = await Promise.all(pageBoards.map(toBoardWithCount));
boards.update((current) => [...current, ...data]);
currentBoardsPage.set(nextPage);
hasBoardsMore.set(newBoards.length === 20);
hasBoardsMore.set(start + PAGE_SIZE < localBoards.length);
} else {
hasBoardsMore.set(false);
}
@ -96,16 +129,22 @@
}
async function handleCreateBoard() {
if (!authStore.user || !boardName.trim()) return;
if (!boardName.trim()) return;
isCreating = true;
try {
const { createBoard } = await import('$lib/api/boards');
const newBoard = await createBoard({
const newLocal: LocalBoard = {
id: crypto.randomUUID(),
name: boardName,
description: boardDescription || undefined,
});
addBoard({ ...newBoard, itemCount: 0 });
description: boardDescription || null,
canvasWidth: 2000,
canvasHeight: 1500,
backgroundColor: '#ffffff',
isPublic: false,
};
const inserted = await boardCollection.insert(newLocal);
const boardWithCount = await toBoardWithCount(inserted);
addBoard(boardWithCount);
showCreateBoardModal.set(false);
boardName = '';
boardDescription = '';
@ -122,7 +161,12 @@
if (!deletingBoard) return;
try {
await deleteBoard(deletingBoard);
// Delete all board items first
const items = await boardItemCollection.getAll({ boardId: deletingBoard });
for (const item of items) {
await boardItemCollection.delete(item.id);
}
await boardCollection.delete(deletingBoard);
removeBoardFromList(deletingBoard);
showDeleteModal = false;
deletingBoard = null;
@ -134,11 +178,34 @@
}
async function handleDuplicateBoard(boardId: string) {
if (!authStore.user) return;
try {
const newBoard = await duplicateBoard(boardId);
addBoard({ ...newBoard, itemCount: 0 });
const original = await boardCollection.get(boardId);
if (!original) throw new Error('Board not found');
const newId = crypto.randomUUID();
const duplicated: LocalBoard = {
id: newId,
name: `${original.name} (Kopie)`,
description: original.description,
canvasWidth: original.canvasWidth,
canvasHeight: original.canvasHeight,
backgroundColor: original.backgroundColor,
isPublic: false,
};
const inserted = await boardCollection.insert(duplicated);
// Duplicate board items
const originalItems = await boardItemCollection.getAll({ boardId });
for (const item of originalItems) {
await boardItemCollection.insert({
...item,
id: crypto.randomUUID(),
boardId: newId,
});
}
const boardWithCount = await toBoardWithCount(inserted);
addBoard(boardWithCount);
toastStore.show('Board dupliziert', 'success');
} catch (error) {
console.error('Error duplicating board:', error);

View file

@ -10,8 +10,9 @@
} from '$lib/stores/images';
import { isUIVisible } from '$lib/stores/ui';
import { tags, selectedTags } from '$lib/stores/tags';
import { getImages } from '$lib/api/images';
import { getAllTags } from '$lib/api/tags';
import { imageCollection, imageTagCollection, tagCollection } from '$lib/data/local-store';
import type { Image } from '$lib/api/images';
import type { LocalImage } from '$lib/data/local-store';
import GalleryGrid from '$lib/components/gallery/GalleryGrid.svelte';
import ImageDetailModal from '$lib/components/gallery/ImageDetailModal.svelte';
import QuickGenerateBar from '$lib/components/gallery/QuickGenerateBar.svelte';
@ -22,10 +23,41 @@
import { Heart } from '@manacore/shared-icons';
import { onMount } from 'svelte';
const PAGE_SIZE = 20;
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
/** Convert LocalImage (IndexedDB) to the Image type used by components. */
function toImage(local: LocalImage): Image {
return {
id: local.id,
userId: 'local',
prompt: local.prompt,
negativePrompt: local.negativePrompt ?? undefined,
model: local.model ?? undefined,
style: local.style ?? undefined,
publicUrl: local.publicUrl ?? undefined,
storagePath: local.storagePath,
filename: local.filename,
format: local.format ?? undefined,
width: local.width ?? undefined,
height: local.height ?? undefined,
fileSize: local.fileSize ?? undefined,
blurhash: local.blurhash ?? undefined,
isPublic: local.isPublic,
isFavorite: local.isFavorite,
downloadCount: local.downloadCount,
rating: local.rating ?? undefined,
archivedAt: local.archivedAt ?? undefined,
generationId: local.generationId ?? undefined,
sourceImageId: local.sourceImageId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
onMount(() => {
loadTags().then(() => {
loadInitialImages();
@ -40,7 +72,7 @@
},
{
threshold: 0.1,
rootMargin: '100px', // Load before reaching the trigger
rootMargin: '100px',
}
);
@ -55,8 +87,15 @@
async function loadTags() {
try {
const data = await getAllTags();
tags.set(data);
const localTags = await tagCollection.getAll();
tags.set(
localTags.map((t) => ({
id: t.id,
name: t.name,
color: t.color ?? undefined,
createdAt: t.createdAt ?? new Date().toISOString(),
}))
);
} catch (error) {
console.error('Error loading tags:', error);
}
@ -70,18 +109,37 @@
});
async function loadInitialImages() {
if (!authStore.user) return;
isLoading.set(true);
try {
const data = await getImages({
page: 1,
tagIds: $selectedTags.length > 0 ? $selectedTags : undefined,
favoritesOnly: $showFavoritesOnly,
});
images.set(data);
let allImages = await imageCollection.getAll();
// Filter out archived images
allImages = allImages.filter((img) => !img.archivedAt);
// Filter favorites
if ($showFavoritesOnly) {
allImages = allImages.filter((img) => img.isFavorite);
}
// Filter by tags
if ($selectedTags.length > 0) {
const allImageTags = await imageTagCollection.getAll();
const imageIdsWithTags = new Set(
allImageTags.filter((it) => $selectedTags.includes(it.tagId)).map((it) => it.imageId)
);
allImages = allImages.filter((img) => imageIdsWithTags.has(img.id));
}
// Sort by createdAt descending
allImages.sort(
(a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
);
// Paginate
const page1 = allImages.slice(0, PAGE_SIZE);
images.set(page1.map(toImage));
currentPage.set(1);
hasMore.set(data.length === 20);
hasMore.set(allImages.length > PAGE_SIZE);
} catch (error) {
console.error('Error loading images:', error);
} finally {
@ -90,21 +148,38 @@
}
async function loadMoreImages() {
if (!authStore.user || !$hasMore || $isLoading || loadingMore) return;
if (!$hasMore || $isLoading || loadingMore) return;
loadingMore = true;
const nextPage = $currentPage + 1;
try {
const newImages = await getImages({
page: nextPage,
tagIds: $selectedTags.length > 0 ? $selectedTags : undefined,
favoritesOnly: $showFavoritesOnly,
});
if (newImages.length > 0) {
images.update((current) => [...current, ...newImages]);
let allImages = await imageCollection.getAll();
allImages = allImages.filter((img) => !img.archivedAt);
if ($showFavoritesOnly) {
allImages = allImages.filter((img) => img.isFavorite);
}
if ($selectedTags.length > 0) {
const allImageTags = await imageTagCollection.getAll();
const imageIdsWithTags = new Set(
allImageTags.filter((it) => $selectedTags.includes(it.tagId)).map((it) => it.imageId)
);
allImages = allImages.filter((img) => imageIdsWithTags.has(img.id));
}
allImages.sort(
(a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
);
const start = (nextPage - 1) * PAGE_SIZE;
const pageImages = allImages.slice(start, start + PAGE_SIZE);
if (pageImages.length > 0) {
images.update((current) => [...current, ...pageImages.map(toImage)]);
currentPage.set(nextPage);
hasMore.set(newImages.length === 20);
hasMore.set(start + PAGE_SIZE < allImages.length);
} else {
hasMore.set(false);
}

View file

@ -41,6 +41,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-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",

View file

@ -0,0 +1,59 @@
/**
* Guest seed data for the Planta app.
*
* Provides a demo plant with watering schedule for the onboarding experience.
*/
import type { LocalPlant, LocalWateringSchedule } from './local-store';
const DEMO_PLANT_ID = 'demo-monstera';
export const guestPlants: LocalPlant[] = [
{
id: DEMO_PLANT_ID,
name: 'Monstera',
scientificName: 'Monstera deliciosa',
commonName: 'Fensterblatt',
lightRequirements: 'bright',
wateringFrequencyDays: 7,
humidity: 'medium',
temperature: '18-24°C',
careNotes: 'Mag indirektes Licht. Erde zwischen dem Gießen leicht antrocknen lassen.',
isActive: true,
healthStatus: 'healthy',
},
{
id: 'demo-cactus',
name: 'Kaktus',
scientificName: 'Echinocactus grusonii',
commonName: 'Schwiegermutterstuhl',
lightRequirements: 'direct',
wateringFrequencyDays: 21,
humidity: 'low',
temperature: '15-30°C',
careNotes: 'Selten gießen, mag viel Sonne.',
isActive: true,
healthStatus: 'healthy',
},
];
export const guestWateringSchedules: LocalWateringSchedule[] = [
{
id: 'schedule-monstera',
plantId: DEMO_PLANT_ID,
frequencyDays: 7,
lastWateredAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
nextWateringAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
reminderEnabled: true,
reminderHoursBefore: 24,
},
{
id: 'schedule-cactus',
plantId: 'demo-cactus',
frequencyDays: 21,
lastWateredAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
nextWateringAt: new Date(Date.now() + 11 * 24 * 60 * 60 * 1000).toISOString(),
reminderEnabled: true,
reminderHoursBefore: 24,
},
];

View file

@ -0,0 +1,94 @@
/**
* Planta Local-First Data Layer
*
* Plants, watering schedules, and watering logs stored locally.
* Photo upload and AI analysis remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestPlants, guestWateringSchedules } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalPlant extends BaseRecord {
name: string;
scientificName?: string | null;
commonName?: string | null;
species?: string | null;
lightRequirements?: 'low' | 'medium' | 'bright' | 'direct' | null;
wateringFrequencyDays?: number | null;
humidity?: 'low' | 'medium' | 'high' | null;
temperature?: string | null;
soilType?: string | null;
careNotes?: string | null;
isActive: boolean;
healthStatus?: 'healthy' | 'needs_attention' | 'sick' | null;
acquiredAt?: string | null;
}
export interface LocalPlantPhoto extends BaseRecord {
plantId: string;
storagePath: string;
publicUrl?: string | null;
filename: string;
mimeType?: string | null;
fileSize?: number | null;
width?: number | null;
height?: number | null;
isPrimary: boolean;
isAnalyzed: boolean;
takenAt?: string | null;
}
export interface LocalWateringSchedule extends BaseRecord {
plantId: string;
frequencyDays: number;
lastWateredAt?: string | null;
nextWateringAt?: string | null;
reminderEnabled: boolean;
reminderHoursBefore: number;
}
export interface LocalWateringLog extends BaseRecord {
plantId: string;
wateredAt: string;
notes?: string | null;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const plantaStore = createLocalStore({
appId: 'planta',
collections: [
{
name: 'plants',
indexes: ['isActive', 'healthStatus'],
guestSeed: guestPlants,
},
{
name: 'plantPhotos',
indexes: ['plantId', 'isPrimary', '[plantId+isPrimary]'],
},
{
name: 'wateringSchedules',
indexes: ['plantId', 'nextWateringAt'],
guestSeed: guestWateringSchedules,
},
{
name: 'wateringLogs',
indexes: ['plantId', 'wateredAt'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const plantCollection = plantaStore.collection<LocalPlant>('plants');
export const plantPhotoCollection = plantaStore.collection<LocalPlantPhoto>('plantPhotos');
export const wateringScheduleCollection =
plantaStore.collection<LocalWateringSchedule>('wateringSchedules');
export const wateringLogCollection = plantaStore.collection<LocalWateringLog>('wateringLogs');

View file

@ -12,10 +12,14 @@
resolvePlantData,
formatParsedPlantPreview,
} from '$lib/utils/plant-parser';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { plantaStore } from '$lib/data/local-store';
let { children } = $props();
let showGuestWelcome = $state(false);
// TagStrip visibility
let isTagStripVisible = $state(false);
function handleTagStripToggle() {
@ -95,9 +99,20 @@
goto(`/plant/${plant.id}`);
}
}
async function handleAuthReady() {
await plantaStore.initialize();
if (authStore.isAuthenticated) {
plantaStore.startSync(() => authStore.getValidToken());
await tagStore.fetchTags();
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('planta')) {
showGuestWelcome = true;
}
}
</script>
<AuthGate {authStore} {goto} onReady={() => tagStore.fetchTags()}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -154,7 +169,18 @@
</div>
</main>
</div>
<SessionExpiredBanner locale="de" loginHref="/login" />
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<GuestWelcomeModal
appId="planta"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
</AuthGate>
<style>

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

View file

@ -49,6 +49,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-subscription-ui": "workspace:*",

View file

@ -0,0 +1,58 @@
/**
* Guest seed data for the Storage app.
*
* Provides demo folders and tags for the onboarding experience.
*/
import type { LocalFolder, LocalTag } from './local-store';
export const guestFolders: LocalFolder[] = [
{
id: 'folder-documents',
name: 'Dokumente',
description: 'Wichtige Dokumente',
color: '#3b82f6',
path: '/folder-documents',
depth: 0,
isFavorite: false,
isDeleted: false,
},
{
id: 'folder-photos',
name: 'Fotos',
description: 'Fotosammlung',
color: '#22c55e',
path: '/folder-photos',
depth: 0,
isFavorite: true,
isDeleted: false,
},
{
id: 'folder-music',
name: 'Musik',
description: 'Audio-Dateien',
color: '#a855f7',
path: '/folder-music',
depth: 0,
isFavorite: false,
isDeleted: false,
},
];
export const guestTags: LocalTag[] = [
{
id: 'tag-important',
name: 'Wichtig',
color: '#ef4444',
},
{
id: 'tag-work',
name: 'Arbeit',
color: '#3b82f6',
},
{
id: 'tag-personal',
name: 'Privat',
color: '#22c55e',
},
];

View file

@ -0,0 +1,84 @@
/**
* Storage Local-First Data Layer
*
* File/folder metadata, tags, and favorites stored locally.
* Actual file upload/download, shares, and versions remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestFolders, guestTags } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalFile extends BaseRecord {
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId?: string | null;
currentVersion: number;
isFavorite: boolean;
isDeleted: boolean;
checksum?: string | null;
thumbnailPath?: string | null;
}
export interface LocalFolder extends BaseRecord {
name: string;
description?: string | null;
color?: string | null;
parentFolderId?: string | null;
path: string;
depth: number;
isFavorite: boolean;
isDeleted: boolean;
}
export interface LocalTag extends BaseRecord {
name: string;
color?: string | null;
}
export interface LocalFileTag extends BaseRecord {
fileId: string;
tagId: string;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const storageStore = createLocalStore({
appId: 'storage',
collections: [
{
name: 'files',
indexes: ['parentFolderId', 'mimeType', 'isFavorite', 'isDeleted', 'name'],
},
{
name: 'folders',
indexes: ['parentFolderId', 'path', 'depth', 'isFavorite', 'isDeleted'],
guestSeed: guestFolders,
},
{
name: 'tags',
indexes: ['name'],
guestSeed: guestTags,
},
{
name: 'fileTags',
indexes: ['fileId', 'tagId', '[fileId+tagId]'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const fileCollection = storageStore.collection<LocalFile>('files');
export const folderCollection = storageStore.collection<LocalFolder>('folders');
export const tagCollection = storageStore.collection<LocalTag>('tags');
export const fileTagCollection = storageStore.collection<LocalFileTag>('fileTags');

View file

@ -17,7 +17,9 @@
import { ToastContainer } from '@manacore/shared-ui';
import { storageOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { storageStore } from '$lib/data/local-store';
import MiniPlayer from '$lib/components/audio/MiniPlayer.svelte';
import FullPlayer from '$lib/components/audio/FullPlayer.svelte';
import '../app.css';
@ -63,8 +65,11 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Guest welcome modal state
let showGuestWelcome = $state(false);
// TagStrip state
let isTagStripVisible = $state(true);
@ -160,11 +165,26 @@
}
async function handleAuthReady() {
// Initialize local-first database
await storageStore.initialize();
// If authenticated, start syncing
if (authStore.isAuthenticated) {
storageStore.startSync(() => authStore.getValidToken());
}
// Initialize theme
theme.initialize();
// Load user settings and tags
await Promise.all([userSettings.load(), tagsStore.fetchTags()]);
// Show guest welcome on first visit
if (!authStore.isAuthenticated && shouldShowGuestWelcome('storage')) {
showGuestWelcome = true;
}
if (authStore.isAuthenticated) {
// Load user settings and tags (require auth)
await Promise.all([userSettings.load(), tagsStore.fetchTags()]);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('storage-nav-collapsed');
@ -252,12 +272,24 @@
<MiniOnboardingModal store={storageOnboarding} appName="Storage" appEmoji="☁️" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<!-- Audio Player -->
<MiniPlayer />
<FullPlayer />
{/if}
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="storage"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
</AuthGate>
<style>