feat(apps): migrate Chat, Questions, Mukke, Context, Photos to local-first

Add IndexedDB data layer to 5 more Tier 2 apps, bringing the total to
17/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} + local store init
- Add GuestWelcomeModal for first-visit experience

App-specific changes:
- Chat: Add store init to existing AuthGate, keep session-based guest mode
- Questions: Replace manual onMount auth with AuthGate wrapper
- Mukke: Add allowGuest to existing AuthGate, audio files stay server-side
- Context: Replace manual onMount auth with AuthGate, AI stays server-side
- Photos: Add allowGuest to existing AuthGate, photos stay on mana-media

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 21:17:56 +01:00
parent a70359dcce
commit 8d880f1fb6
21 changed files with 827 additions and 174 deletions

View file

@ -538,6 +538,11 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
| NutriPhi | meals, goals, favorites | Done |
| Planta | plants, plantPhotos, wateringSchedules, wateringLogs | Done |
| Storage | files, folders, tags, fileTags | Done |
| Chat | conversations, messages, templates | Done |
| Questions | collections, questions, answers | Done |
| Mukke | songs, playlists, playlistSongs, projects, markers | Done |
| Context | spaces, documents | Done |
| Photos | albums, albumItems, favorites, tags, photoTags | Done |
### Dev Commands (Local-First Stack)

View file

@ -37,6 +37,7 @@
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -0,0 +1,30 @@
/**
* Guest seed data for the Chat app.
*
* Provides a demo conversation to showcase the chat experience.
*/
import type { LocalConversation, LocalMessage } from './local-store';
const DEMO_CONVERSATION_ID = 'demo-welcome';
export const guestConversations: LocalConversation[] = [
{
id: DEMO_CONVERSATION_ID,
title: 'Willkommen bei Chat!',
conversationMode: 'free',
documentMode: false,
isArchived: false,
isPinned: true,
},
];
export const guestMessages: LocalMessage[] = [
{
id: 'msg-1',
conversationId: DEMO_CONVERSATION_ID,
sender: 'assistant',
messageText:
'Hallo! Ich bin dein KI-Assistent. Du kannst mir Fragen stellen, Texte schreiben lassen oder einfach ein Gespräch führen. Melde dich an, um deine Unterhaltungen zu speichern und zu synchronisieren.',
},
];

View file

@ -0,0 +1,71 @@
/**
* Chat Local-First Data Layer
*
* Conversations, messages, and templates stored locally.
* LLM streaming and model management remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestConversations, guestMessages } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalConversation extends BaseRecord {
title?: string | null;
modelId?: string | null;
templateId?: string | null;
spaceId?: string | null;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
}
export interface LocalMessage extends BaseRecord {
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
}
export interface LocalTemplate extends BaseRecord {
name: string;
description: string;
systemPrompt: string;
initialQuestion?: string | null;
modelId?: string | null;
color: string;
isDefault: boolean;
documentMode: boolean;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const chatStore = createLocalStore({
appId: 'chat',
collections: [
{
name: 'conversations',
indexes: ['isArchived', 'isPinned', 'spaceId', 'templateId'],
guestSeed: guestConversations,
},
{
name: 'messages',
indexes: ['conversationId', 'sender', '[conversationId+sender]'],
guestSeed: guestMessages,
},
{
name: 'templates',
indexes: ['isDefault'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const conversationCollection = chatStore.collection<LocalConversation>('conversations');
export const messageCollection = chatStore.collection<LocalMessage>('messages');
export const templateCollection = chatStore.collection<LocalTemplate>('templates');

View file

@ -24,7 +24,9 @@
import type { LayoutData } from './$types';
import { chatOnboarding } 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 { chatStore } from '$lib/data/local-store';
// App switcher items
const appItems = getPillAppItems('chat');
@ -156,7 +158,18 @@
goto('/login');
}
let showGuestWelcome = $state(false);
async function handleAuthReady() {
// Initialize local-first database
await chatStore.initialize();
if (authStore.isAuthenticated) {
chatStore.startSync(() => authStore.getValidToken());
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('chat')) {
showGuestWelcome = true;
}
// Initialize theme
theme.initialize();
@ -167,6 +180,8 @@
collapsedStore.set(true);
}
if (!authStore.isAuthenticated) return;
// Load user settings and tags
await userSettings.load();
await tagStore.fetchTags();
@ -186,7 +201,7 @@
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating Pill Navigation -->
@ -256,7 +271,18 @@
<MiniOnboardingModal store={chatOnboarding} appName="Chat" appEmoji="💬" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<GuestWelcomeModal
appId="chat"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
</AuthGate>
<style>

View file

@ -36,6 +36,7 @@
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -0,0 +1,43 @@
/**
* Guest seed data for the Context app.
*
* Provides a demo space with sample documents.
*/
import type { LocalSpace, LocalDocument } from './local-store';
const DEMO_SPACE_ID = 'demo-workspace';
export const guestSpaces: LocalSpace[] = [
{
id: DEMO_SPACE_ID,
name: 'Mein Workspace',
description: 'Beispiel-Space zum Kennenlernen von Context.',
pinned: true,
prefix: 'W',
},
];
export const guestDocuments: LocalDocument[] = [
{
id: 'doc-welcome',
spaceId: DEMO_SPACE_ID,
title: 'Willkommen bei Context',
content:
'Context ist dein KI-gestütztes Dokumenten-Management. Erstelle Texte, sammle Kontexte und nutze KI-Prompts.\n\nMelde dich an, um deine Dokumente zu synchronisieren.',
type: 'text',
shortId: 'WD1',
pinned: true,
metadata: { tags: ['einführung'], wordCount: 22 },
},
{
id: 'doc-prompt',
spaceId: DEMO_SPACE_ID,
title: 'Beispiel-Prompt',
content: 'Fasse den folgenden Text in 3 Stichpunkten zusammen:\n\n{text}',
type: 'prompt',
shortId: 'WP1',
pinned: false,
metadata: { tags: ['vorlage'] },
},
];

View file

@ -0,0 +1,59 @@
/**
* Context Local-First Data Layer
*
* Spaces and documents stored locally.
* AI generation and token management remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestSpaces, guestDocuments } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalSpace extends BaseRecord {
name: string;
description?: string | null;
settings?: Record<string, unknown> | null;
pinned: boolean;
prefix: string;
}
export interface LocalDocument extends BaseRecord {
spaceId?: string | null;
title: string;
content: string;
type: 'text' | 'context' | 'prompt';
shortId?: string | null;
pinned: boolean;
metadata?: {
tags?: string[];
wordCount?: number;
} | null;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const contextStore = createLocalStore({
appId: 'context',
collections: [
{
name: 'spaces',
indexes: ['pinned', 'prefix'],
guestSeed: guestSpaces,
},
{
name: 'documents',
indexes: ['spaceId', 'type', 'pinned', 'title', '[spaceId+type]'],
guestSeed: guestDocuments,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const spaceCollection = contextStore.collection<LocalSpace>('spaces');
export const documentCollection = contextStore.collection<LocalDocument>('documents');

View file

@ -28,6 +28,9 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import { contextOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { contextStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
const appItems = getPillAppItems('context');
@ -212,11 +215,15 @@
goto('/login');
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await contextStore.initialize();
if (authStore.isAuthenticated) {
contextStore.startSync(() => authStore.getValidToken());
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('context')) {
showGuestWelcome = true;
}
const savedCollapsed = localStorage.getItem('context-nav-collapsed');
@ -225,90 +232,99 @@
collapsedStore.set(true);
}
await userSettings.load();
// Load tags
await tagStore.fetchTags();
// Pre-load data for CommandBar search
await Promise.all([spacesStore.load(), documentsStore.load()]);
});
if (authStore.isAuthenticated) {
await userSettings.load();
await tagStore.fetchTags();
await Promise.all([spacesStore.load(), documentsStore.load()]);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Context"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#0ea5e9"
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} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Context"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#0ea5e9"
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}
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
<!-- Onboarding Modal -->
{#if contextOnboarding.shouldShow}
<MiniOnboardingModal store={contextOnboarding} appName="Context" appEmoji="📄" />
{/if}
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
<GuestWelcomeModal
appId="context"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
</div>
<!-- Onboarding Modal -->
{#if contextOnboarding.shouldShow}
<MiniOnboardingModal store={contextOnboarding} appName="Context" appEmoji="📄" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -39,6 +39,7 @@
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-feedback-service": "workspace:^",

View file

@ -0,0 +1,15 @@
/**
* Guest seed data for the Mukke app.
*
* Provides a demo playlist. Songs require upload, so no audio seeds.
*/
import type { LocalPlaylist } from './local-store';
export const guestPlaylists: LocalPlaylist[] = [
{
id: 'playlist-favorites',
name: 'Meine Favoriten',
description: 'Lade Songs hoch und füge sie zu dieser Playlist hinzu.',
},
];

View file

@ -0,0 +1,98 @@
/**
* Mukke Local-First Data Layer
*
* Song metadata, playlists, projects, and markers stored locally.
* Audio file upload/streaming remains server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestPlaylists } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalSong extends BaseRecord {
title: string;
artist?: string | null;
album?: string | null;
albumArtist?: string | null;
genre?: string | null;
trackNumber?: number | null;
year?: number | null;
duration?: number | null;
storagePath: string;
coverArtPath?: string | null;
fileSize?: number | null;
bpm?: number | null;
favorite: boolean;
playCount: number;
lastPlayedAt?: string | null;
}
export interface LocalPlaylist extends BaseRecord {
name: string;
description?: string | null;
coverArtPath?: string | null;
}
export interface LocalPlaylistSong extends BaseRecord {
playlistId: string;
songId: string;
sortOrder: number;
}
export interface LocalProject extends BaseRecord {
title: string;
description?: string | null;
songId?: string | null;
}
export interface LocalMarker extends BaseRecord {
beatId: string;
type: 'verse' | 'hook' | 'bridge' | 'intro' | 'outro' | 'drop' | 'breakdown' | 'custom';
label?: string | null;
startTime: number;
endTime?: number | null;
color?: string | null;
sortOrder: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const mukkeStore = createLocalStore({
appId: 'mukke',
collections: [
{
name: 'songs',
indexes: ['artist', 'album', 'genre', 'favorite', 'title'],
},
{
name: 'playlists',
indexes: ['name'],
guestSeed: guestPlaylists,
},
{
name: 'playlistSongs',
indexes: ['playlistId', 'songId', 'sortOrder', '[playlistId+sortOrder]'],
},
{
name: 'projects',
indexes: ['title', 'songId'],
},
{
name: 'markers',
indexes: ['beatId', 'type', 'sortOrder'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const songCollection = mukkeStore.collection<LocalSong>('songs');
export const playlistCollection = mukkeStore.collection<LocalPlaylist>('playlists');
export const playlistSongCollection = mukkeStore.collection<LocalPlaylistSong>('playlistSongs');
export const projectCollection = mukkeStore.collection<LocalProject>('projects');
export const markerCollection = mukkeStore.collection<LocalMarker>('markers');

View file

@ -26,7 +26,9 @@
import { playlistStore } from '$lib/stores/playlist.svelte';
import { projectStore } from '$lib/stores/project.svelte';
import { parseSongInput, formatParsedSongPreview } from '$lib/utils/song-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 { mukkeStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
import MiniPlayer from '$lib/components/MiniPlayer.svelte';
import FullPlayer from '$lib/components/FullPlayer.svelte';
@ -180,15 +182,26 @@
goto('/projects');
}
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await mukkeStore.initialize();
if (authStore.isAuthenticated) {
mukkeStore.startSync(() => authStore.getValidToken());
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('mukke')) {
showGuestWelcome = true;
}
splitPanel.initialize();
await tagStore.fetchTags();
if (authStore.isAuthenticated) {
await tagStore.fetchTags();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<SplitPaneContainer>
<div class="layout-container">
<a
@ -272,7 +285,18 @@
<DevBuildBadge commitHash={__BUILD_HASH__} buildTime={__BUILD_TIME__} />
</div>
</SplitPaneContainer>
<SessionExpiredBanner locale="de" loginHref="/login" />
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<GuestWelcomeModal
appId="mukke"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
</AuthGate>
<style>

View file

@ -33,6 +33,7 @@
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -0,0 +1,34 @@
/**
* Guest seed data for the Photos app.
*
* Provides demo albums and tags. Actual photos require mana-media.
*/
import type { LocalAlbum, LocalTag } from './local-store';
export const guestAlbums: LocalAlbum[] = [
{
id: 'album-favorites',
name: 'Favoriten',
description: 'Deine Lieblingsfotos an einem Ort.',
isAutoGenerated: false,
},
];
export const guestTags: LocalTag[] = [
{
id: 'tag-nature',
name: 'Natur',
color: '#22c55e',
},
{
id: 'tag-people',
name: 'Menschen',
color: '#3b82f6',
},
{
id: 'tag-travel',
name: 'Reisen',
color: '#f59e0b',
},
];

View file

@ -0,0 +1,82 @@
/**
* Photos Local-First Data Layer
*
* Albums, favorites, and tags stored locally.
* Photo files remain on mana-media (server-side).
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestAlbums, guestTags } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalAlbum extends BaseRecord {
name: string;
description?: string | null;
coverMediaId?: string | null;
isAutoGenerated: boolean;
autoGenerateType?: 'date' | 'location' | 'camera' | null;
autoGenerateValue?: string | null;
}
export interface LocalAlbumItem extends BaseRecord {
albumId: string;
mediaId: string;
sortOrder: number;
}
export interface LocalFavorite extends BaseRecord {
mediaId: string;
}
export interface LocalTag extends BaseRecord {
name: string;
color?: string | null;
}
export interface LocalPhotoTag extends BaseRecord {
mediaId: string;
tagId: string;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const photosStore = createLocalStore({
appId: 'photos',
collections: [
{
name: 'albums',
indexes: ['isAutoGenerated', 'name'],
guestSeed: guestAlbums,
},
{
name: 'albumItems',
indexes: ['albumId', 'mediaId', 'sortOrder', '[albumId+sortOrder]'],
},
{
name: 'favorites',
indexes: ['mediaId'],
},
{
name: 'tags',
indexes: ['name'],
guestSeed: guestTags,
},
{
name: 'photoTags',
indexes: ['mediaId', 'tagId', '[mediaId+tagId]'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const albumCollection = photosStore.collection<LocalAlbum>('albums');
export const albumItemCollection = photosStore.collection<LocalAlbumItem>('albumItems');
export const favoriteCollection = photosStore.collection<LocalFavorite>('favorites');
export const tagCollection = photosStore.collection<LocalTag>('tags');
export const photoTagCollection = photosStore.collection<LocalPhotoTag>('photoTags');

View file

@ -11,7 +11,9 @@
import { tagStore } from '$lib/stores/tags.svelte';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
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 { photosStore } from '$lib/data/local-store';
let { children } = $props();
@ -106,12 +108,21 @@
}
}
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
await photosStore.initialize();
if (authStore.isAuthenticated) {
photosStore.startSync(() => authStore.getValidToken());
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('photos')) {
showGuestWelcome = true;
}
}
</script>
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -171,7 +182,18 @@
</div>
</main>
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<GuestWelcomeModal
appId="photos"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
</AuthGate>
<style>

View file

@ -37,6 +37,7 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-i18n": "workspace:*",

View file

@ -0,0 +1,43 @@
/**
* Guest seed data for the Questions app.
*
* Provides demo collections and questions for the onboarding experience.
*/
import type { LocalCollection, LocalQuestion } from './local-store';
const DEMO_COLLECTION_ID = 'demo-research';
export const guestCollections: LocalCollection[] = [
{
id: DEMO_COLLECTION_ID,
name: 'Erste Recherche',
description: 'Beispiel-Sammlung zum Kennenlernen.',
color: '#6366f1',
icon: 'search',
isDefault: true,
sortOrder: 0,
},
];
export const guestQuestions: LocalQuestion[] = [
{
id: 'q-1',
collectionId: DEMO_COLLECTION_ID,
title: 'Was ist Local-First Software?',
description: 'Wie funktioniert der Ansatz und welche Vorteile hat er?',
status: 'open',
priority: 'normal',
tags: ['tech', 'architektur'],
researchDepth: 'standard',
},
{
id: 'q-2',
collectionId: DEMO_COLLECTION_ID,
title: 'Welche Datenbanken eignen sich für Offline-First Apps?',
status: 'open',
priority: 'normal',
tags: ['tech', 'datenbank'],
researchDepth: 'quick',
},
];

View file

@ -0,0 +1,71 @@
/**
* Questions Local-First Data Layer
*
* Questions, collections, and answers stored locally.
* Research (via mana-search) remains server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestCollections, guestQuestions } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalCollection extends BaseRecord {
name: string;
description?: string | null;
color: string;
icon: string;
isDefault: boolean;
sortOrder: number;
}
export interface LocalQuestion extends BaseRecord {
collectionId?: string | null;
title: string;
description?: string | null;
status: 'open' | 'researching' | 'answered' | 'archived';
priority: 'low' | 'normal' | 'high' | 'urgent';
tags: string[];
researchDepth: 'quick' | 'standard' | 'deep';
}
export interface LocalAnswer extends BaseRecord {
questionId: string;
researchResultId?: string | null;
content: string;
citations: Array<{ sourceId: string; text: string }>;
rating?: number | null;
isAccepted: boolean;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const questionsAppStore = createLocalStore({
appId: 'questions',
collections: [
{
name: 'collections',
indexes: ['sortOrder', 'isDefault'],
guestSeed: guestCollections,
},
{
name: 'questions',
indexes: ['collectionId', 'status', 'priority', '[collectionId+status]'],
guestSeed: guestQuestions,
},
{
name: 'answers',
indexes: ['questionId', 'isAccepted'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const collectionCollection = questionsAppStore.collection<LocalCollection>('collections');
export const questionCollection = questionsAppStore.collection<LocalQuestion>('questions');
export const answerCollection = questionsAppStore.collection<LocalAnswer>('answers');

View file

@ -8,6 +8,9 @@
import { apiClient } from '$lib/api/client';
import { questionsApi } from '$lib/api/questions';
import { theme } from '$lib/stores/theme';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { questionsAppStore } from '$lib/data/local-store';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type {
PillNavItem,
@ -41,34 +44,29 @@
// User email for nav
let userEmail = $derived(authStore.user?.email || 'Menu');
onMount(async () => {
// Initialize auth and redirect if not authenticated
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await questionsAppStore.initialize();
if (authStore.isAuthenticated) {
questionsAppStore.startSync(() => authStore.getValidToken());
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
await collectionsStore.load();
await questionsStore.load();
await tagStore.fetchTags();
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('questions')) {
showGuestWelcome = true;
}
// Set API token
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
// Load initial data
await collectionsStore.load();
await questionsStore.load();
await tagStore.fetchTags();
// Initialize mobile state
updateMobileState();
// Restore nav mode from localStorage
if (browser) {
const savedCollapsed = localStorage.getItem('questions-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
}
});
}
async function handleSignOut() {
await authStore.signOut();
@ -199,71 +197,82 @@
<svelte:window onresize={updateMobileState} />
<div class="layout-container">
<!-- 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} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<!-- 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}
<!-- Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Questions"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showLogout={true}
onLogout={handleSignOut}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
themesHref="/themes"
helpHref="/help"
profileHref="/profile"
/>
{/if}
<!-- Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Questions"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showLogout={true}
onLogout={handleSignOut}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
themesHref="/themes"
helpHref="/help"
profileHref="/profile"
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="New question or search..."
emptyText="No questions found"
searchingText="Searching..."
searchText="Search"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Create"
deferSearch={true}
locale={$locale || 'en'}
appIcon="help-circle"
bottomOffset={isMobile ? '70px' : '70px'}
/>
<!-- Main Content -->
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<GuestWelcomeModal
appId="questions"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="New question or search..."
emptyText="No questions found"
searchingText="Searching..."
searchText="Search"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Create"
deferSearch={true}
locale={$locale || 'en'}
appIcon="help-circle"
bottomOffset={isMobile ? '70px' : '70px'}
/>
<!-- Main Content -->
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
</AuthGate>
<style>
.layout-container {