mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
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:
parent
8f40de2966
commit
ce51fd5fe2
31 changed files with 1623 additions and 211 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
100
apps/inventar/apps/web/src/lib/data/guest-seed.ts
Normal file
100
apps/inventar/apps/web/src/lib/data/guest-seed.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
120
apps/inventar/apps/web/src/lib/data/local-store.ts
Normal file
120
apps/inventar/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
55
apps/nutriphi/apps/web/src/lib/data/guest-seed.ts
Normal file
55
apps/nutriphi/apps/web/src/lib/data/guest-seed.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
83
apps/nutriphi/apps/web/src/lib/data/local-store.ts
Normal file
83
apps/nutriphi/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
131
apps/picture/apps/web/src/lib/data/guest-seed.ts
Normal file
131
apps/picture/apps/web/src/lib/data/guest-seed.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
116
apps/picture/apps/web/src/lib/data/local-store.ts
Normal file
116
apps/picture/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
59
apps/planta/apps/web/src/lib/data/guest-seed.ts
Normal file
59
apps/planta/apps/web/src/lib/data/guest-seed.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
94
apps/planta/apps/web/src/lib/data/local-store.ts
Normal file
94
apps/planta/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
62
apps/presi/apps/web/src/lib/data/guest-seed.ts
Normal file
62
apps/presi/apps/web/src/lib/data/guest-seed.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
58
apps/presi/apps/web/src/lib/data/local-store.ts
Normal file
58
apps/presi/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
58
apps/storage/apps/web/src/lib/data/guest-seed.ts
Normal file
58
apps/storage/apps/web/src/lib/data/guest-seed.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
84
apps/storage/apps/web/src/lib/data/local-store.ts
Normal file
84
apps/storage/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue