From b16e245fe3fa5265571845a69dcaa4f6373cc70c Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 27 Mar 2026 12:05:01 +0100 Subject: [PATCH] feat(zitare): migrate to local-first with Dexie.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Favorites and lists stores rewritten to read/write IndexedDB - Guest seed data: 3 pre-favorited quotes + sample list - Layout: zitareStore.initialize(), sync on login, GuestWelcomeModal - PillNav shows login button for guests (empty userEmail) - No auth checks in stores — all writes are local Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/zitare/apps/web/package.json | 1 + .../apps/web/src/lib/data/guest-seed.ts | 22 + .../apps/web/src/lib/data/local-store.ts | 43 + .../web/src/lib/stores/favorites.svelte.ts | 95 +- .../apps/web/src/lib/stores/lists.svelte.ts | 157 +-- .../apps/web/src/routes/(app)/+layout.svelte | 36 +- pnpm-lock.yaml | 966 +++++++++--------- 7 files changed, 649 insertions(+), 671 deletions(-) create mode 100644 apps/zitare/apps/web/src/lib/data/guest-seed.ts create mode 100644 apps/zitare/apps/web/src/lib/data/local-store.ts diff --git a/apps/zitare/apps/web/package.json b/apps/zitare/apps/web/package.json index 9fbac3786..81ac41dfd 100644 --- a/apps/zitare/apps/web/package.json +++ b/apps/zitare/apps/web/package.json @@ -32,6 +32,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/local-store": "workspace:*", "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", diff --git a/apps/zitare/apps/web/src/lib/data/guest-seed.ts b/apps/zitare/apps/web/src/lib/data/guest-seed.ts new file mode 100644 index 000000000..a7a5e6805 --- /dev/null +++ b/apps/zitare/apps/web/src/lib/data/guest-seed.ts @@ -0,0 +1,22 @@ +/** + * Guest seed data for Zitare. + * Pre-favorited quotes and a sample list to showcase the app. + */ + +import type { LocalFavorite, LocalQuoteList } from './local-store'; + +// Some well-known quote IDs from the content package +export const guestFavorites: LocalFavorite[] = [ + { id: 'fav-1', quoteId: 'mot-1' }, + { id: 'fav-2', quoteId: 'weis-3' }, + { id: 'fav-3', quoteId: 'mot-7' }, +]; + +export const guestLists: LocalQuoteList[] = [ + { + id: 'list-onboarding', + name: 'Meine Lieblingszitate', + description: 'Eine Beispiel-Sammlung zum Ausprobieren', + quoteIds: ['mot-1', 'weis-3'], + }, +]; diff --git a/apps/zitare/apps/web/src/lib/data/local-store.ts b/apps/zitare/apps/web/src/lib/data/local-store.ts new file mode 100644 index 000000000..efab6cc03 --- /dev/null +++ b/apps/zitare/apps/web/src/lib/data/local-store.ts @@ -0,0 +1,43 @@ +/** + * Zitare — Local-First Data Layer + * + * Collections: favorites, lists + * Quotes themselves are static content from @zitare/content, not synced. + */ + +import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import { guestFavorites, guestLists } from './guest-seed'; + +export interface LocalFavorite extends BaseRecord { + quoteId: string; +} + +export interface LocalQuoteList extends BaseRecord { + name: string; + description?: string | null; + quoteIds: string[]; +} + +const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + +export const zitareStore = createLocalStore({ + appId: 'zitare', + collections: [ + { + name: 'favorites', + indexes: ['quoteId'], + guestSeed: guestFavorites, + }, + { + name: 'lists', + indexes: [], + guestSeed: guestLists, + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +export const favoriteCollection = zitareStore.collection('favorites'); +export const listCollection = zitareStore.collection('lists'); diff --git a/apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts b/apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts index bf156d803..56aa3916d 100644 --- a/apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts @@ -1,9 +1,9 @@ /** - * Favorites Store - Manages user's favorite quotes + * Favorites Store — Local-First with Dexie.js + * All reads/writes go to IndexedDB. Sync happens in background when authenticated. */ -import { browser } from '$app/environment'; -import { authStore } from './auth.svelte'; +import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store'; interface Favorite { id: string; @@ -16,37 +16,12 @@ let favorites = $state([]); let loading = $state(false); let initialized = $state(false); -// Get backend URL -function getBackendUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) - .__PUBLIC_BACKEND_URL__; - return injectedUrl || 'http://localhost:3007'; - } - return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3007'; -} - -async function fetchWithAuth(path: string, options: RequestInit = {}) { - const token = await authStore.getValidToken(); - if (!token) { - throw new Error('Not authenticated'); - } - - const response = await fetch(`${getBackendUrl()}/api${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); +function toFavorite(local: LocalFavorite): Favorite { + return { + id: local.id, + quoteId: local.quoteId, + createdAt: local.createdAt ?? new Date().toISOString(), + }; } export const favoritesStore = { @@ -60,27 +35,15 @@ export const favoritesStore = { return initialized; }, - /** - * Check if a quote is favorited - */ isFavorite(quoteId: string): boolean { return favorites.some((f) => f.quoteId === quoteId); }, - /** - * Load favorites from backend - */ async load() { - if (!authStore.isAuthenticated) { - favorites = []; - initialized = true; - return; - } - loading = true; try { - const data = await fetchWithAuth('/favorites'); - favorites = data.favorites || []; + const localFavs = await favoriteCollection.getAll(); + favorites = localFavs.map(toFavorite); initialized = true; } catch (error) { console.error('Failed to load favorites:', error); @@ -90,44 +53,33 @@ export const favoritesStore = { } }, - /** - * Add a quote to favorites - */ async add(quoteId: string) { - if (!authStore.isAuthenticated) return; - try { - const data = await fetchWithAuth('/favorites', { - method: 'POST', - body: JSON.stringify({ quoteId }), - }); - favorites = [...favorites, data.favorite]; + const newFav: LocalFavorite = { + id: crypto.randomUUID(), + quoteId, + }; + const inserted = await favoriteCollection.insert(newFav); + favorites = [...favorites, toFavorite(inserted)]; } catch (error) { console.error('Failed to add favorite:', error); throw error; } }, - /** - * Remove a quote from favorites - */ async remove(quoteId: string) { - if (!authStore.isAuthenticated) return; - try { - await fetchWithAuth(`/favorites/${quoteId}`, { - method: 'DELETE', - }); - favorites = favorites.filter((f) => f.quoteId !== quoteId); + const fav = favorites.find((f) => f.quoteId === quoteId); + if (fav) { + await favoriteCollection.delete(fav.id); + favorites = favorites.filter((f) => f.quoteId !== quoteId); + } } catch (error) { console.error('Failed to remove favorite:', error); throw error; } }, - /** - * Toggle favorite status - */ async toggle(quoteId: string) { if (this.isFavorite(quoteId)) { await this.remove(quoteId); @@ -136,9 +88,6 @@ export const favoritesStore = { } }, - /** - * Clear all favorites (client-side only) - */ clear() { favorites = []; initialized = false; diff --git a/apps/zitare/apps/web/src/lib/stores/lists.svelte.ts b/apps/zitare/apps/web/src/lib/stores/lists.svelte.ts index c1296db12..005ef0a66 100644 --- a/apps/zitare/apps/web/src/lib/stores/lists.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/lists.svelte.ts @@ -1,10 +1,8 @@ -// Lists store - integrates with Zitare backend API -import { browser } from '$app/environment'; -import { authStore } from './auth.svelte'; +/** + * Lists Store — Local-First with Dexie.js + */ -const API_URL = browser - ? import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007' - : 'http://localhost:3007'; +import { listCollection, type LocalQuoteList } from '$lib/data/local-store'; export interface QuoteList { id: string; @@ -19,38 +17,23 @@ let lists = $state([]); let isLoading = $state(false); let error = $state(null); -async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { - const token = await authStore.getValidToken(); - if (!token) { - throw new Error('Not authenticated'); - } - - return fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); +function toQuoteList(local: LocalQuoteList): QuoteList { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + quoteIds: local.quoteIds, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; } async function loadLists() { - if (!authStore.isAuthenticated) { - lists = []; - return; - } - isLoading = true; error = null; - try { - const response = await fetchWithAuth(`${API_URL}/lists`); - if (!response.ok) { - throw new Error('Failed to load lists'); - } - const data = await response.json(); - lists = data.lists || []; + const localLists = await listCollection.getAll(); + lists = localLists.map(toQuoteList); } catch (e) { error = e instanceof Error ? e.message : 'Failed to load lists'; lists = []; @@ -60,37 +43,20 @@ async function loadLists() { } async function getList(id: string): Promise { - if (!authStore.isAuthenticated) { - return null; - } - - try { - const response = await fetchWithAuth(`${API_URL}/lists/${id}`); - if (!response.ok) { - return null; - } - const data = await response.json(); - return data.list || null; - } catch { - return null; - } + const local = await listCollection.get(id); + return local ? toQuoteList(local) : null; } async function createList(name: string, description?: string): Promise { - if (!authStore.isAuthenticated) { - return null; - } - try { - const response = await fetchWithAuth(`${API_URL}/lists`, { - method: 'POST', - body: JSON.stringify({ name, description }), - }); - if (!response.ok) { - throw new Error('Failed to create list'); - } - const data = await response.json(); - const newList = data.list; + const newLocal: LocalQuoteList = { + id: crypto.randomUUID(), + name, + description: description ?? null, + quoteIds: [], + }; + const inserted = await listCollection.insert(newLocal); + const newList = toQuoteList(inserted); lists = [...lists, newList]; return newList; } catch { @@ -102,39 +68,22 @@ async function updateList( id: string, updates: { name?: string; description?: string } ): Promise { - if (!authStore.isAuthenticated) { - return null; - } - try { - const response = await fetchWithAuth(`${API_URL}/lists/${id}`, { - method: 'PUT', - body: JSON.stringify(updates), - }); - if (!response.ok) { - throw new Error('Failed to update list'); + const updated = await listCollection.update(id, updates as Partial); + if (updated) { + const updatedList = toQuoteList(updated); + lists = lists.map((l) => (l.id === id ? updatedList : l)); + return updatedList; } - const data = await response.json(); - const updatedList = data.list; - lists = lists.map((l) => (l.id === id ? updatedList : l)); - return updatedList; + return null; } catch { return null; } } async function deleteList(id: string): Promise { - if (!authStore.isAuthenticated) { - return false; - } - try { - const response = await fetchWithAuth(`${API_URL}/lists/${id}`, { - method: 'DELETE', - }); - if (!response.ok) { - throw new Error('Failed to delete list'); - } + await listCollection.delete(id); lists = lists.filter((l) => l.id !== id); return true; } catch { @@ -143,20 +92,19 @@ async function deleteList(id: string): Promise { } async function addQuoteToList(listId: string, quoteId: string): Promise { - if (!authStore.isAuthenticated) { - return false; - } - try { - const response = await fetchWithAuth(`${API_URL}/lists/${listId}/quotes`, { - method: 'POST', - body: JSON.stringify({ quoteId }), - }); - if (!response.ok) { - throw new Error('Failed to add quote to list'); + const existing = await listCollection.get(listId); + if (!existing) return false; + + const quoteIds = [...(existing.quoteIds || [])]; + if (!quoteIds.includes(quoteId)) { + quoteIds.push(quoteId); + } + + const updated = await listCollection.update(listId, { quoteIds } as Partial); + if (updated) { + lists = lists.map((l) => (l.id === listId ? toQuoteList(updated) : l)); } - const data = await response.json(); - lists = lists.map((l) => (l.id === listId ? data.list : l)); return true; } catch { return false; @@ -164,19 +112,16 @@ async function addQuoteToList(listId: string, quoteId: string): Promise } async function removeQuoteFromList(listId: string, quoteId: string): Promise { - if (!authStore.isAuthenticated) { - return false; - } - try { - const response = await fetchWithAuth(`${API_URL}/lists/${listId}/quotes/${quoteId}`, { - method: 'DELETE', - }); - if (!response.ok) { - throw new Error('Failed to remove quote from list'); + const existing = await listCollection.get(listId); + if (!existing) return false; + + const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId); + + const updated = await listCollection.update(listId, { quoteIds } as Partial); + if (updated) { + lists = lists.map((l) => (l.id === listId ? toQuoteList(updated) : l)); } - const data = await response.json(); - lists = lists.map((l) => (l.id === listId ? data.list : l)); return true; } catch { return false; diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index 64f2b533f..e5894305a 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -31,8 +31,12 @@ import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; - 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 { QUOTES, type Quote } from '@zitare/content'; + import { zitareStore } from '$lib/data/local-store'; + + let showGuestWelcome = $state(false); // App switcher items const appItems = getPillAppItems('zitare'); @@ -92,7 +96,9 @@ let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); // User email for user dropdown - let userEmail = $derived(authStore.user?.email || $_('nav.menu')); + let userEmail = $derived( + authStore.isAuthenticated ? authStore.user?.email || $_('nav.menu') : '' + ); // TagStrip visibility let isTagStripVisible = $state(false); @@ -227,15 +233,22 @@ } async function handleAuthReady() { + // Initialize local-first database + await zitareStore.initialize(); + // Initialize settings zitareSettings.initialize(); - // Load user settings and favorites if authenticated + // Load favorites and lists from IndexedDB (works for guests and auth) + await favoritesStore.load(); + await listsStore.loadLists(); + if (authStore.isAuthenticated) { + zitareStore.startSync(() => authStore.getValidToken()); userSettings.load(); - favoritesStore.load(); - listsStore.loadLists(); tagStore.fetchTags(); + } else if (shouldShowGuestWelcome('zitare')) { + showGuestWelcome = true; } } @@ -354,7 +367,18 @@ - + (showGuestWelcome = false)} + onLogin={() => goto('/login')} + onRegister={() => goto('/register')} + locale={($locale || 'de') === 'de' ? 'de' : 'en'} + /> + + {#if authStore.isAuthenticated} + + {/if}