feat(zitare): migrate to local-first with Dexie.js

- 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 12:05:01 +01:00
parent fe8f0a227d
commit b16e245fe3
7 changed files with 649 additions and 671 deletions

View file

@ -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:*",

View file

@ -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'],
},
];

View file

@ -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<LocalFavorite>('favorites');
export const listCollection = zitareStore.collection<LocalQuoteList>('lists');

View file

@ -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<Favorite[]>([]);
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;

View file

@ -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<QuoteList[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
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<QuoteList | null> {
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<QuoteList | null> {
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<QuoteList | null> {
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<LocalQuoteList>);
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<boolean> {
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<boolean> {
}
async function addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
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<LocalQuoteList>);
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<boolean>
}
async function removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
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<LocalQuoteList>);
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;

View file

@ -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;
}
}
</script>
@ -354,7 +367,18 @@
</div>
</main>
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<GuestWelcomeModal
appId="zitare"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>

966
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff