mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
fe8f0a227d
commit
b16e245fe3
7 changed files with 649 additions and 671 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
22
apps/zitare/apps/web/src/lib/data/guest-seed.ts
Normal file
22
apps/zitare/apps/web/src/lib/data/guest-seed.ts
Normal 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'],
|
||||
},
|
||||
];
|
||||
43
apps/zitare/apps/web/src/lib/data/local-store.ts
Normal file
43
apps/zitare/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
966
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue