feat(local-first): migrate 9 apps to reactive useLiveQuery reads

Replace manual $state + fetchX() pattern with Dexie liveQuery hooks
across 9 apps. All data reads now auto-update on IndexedDB changes
(local writes, sync, other tabs). Stores reduced to mutation-only.

Apps migrated:
- Zitare: favorites, lists
- Contacts: contacts
- Calendar: calendars, events
- Chat: conversations, templates
- Clock: alarms, timers, worldClocks
- ManaDeck: decks, cards
- Presi: decks, slides
- Context: spaces, documents
- Storage: files, folders

Pattern per app:
1. New queries.ts with useLiveQuery hooks + pure filter helpers
2. Stores slimmed to mutation-only (no $state arrays, no fetch methods)
3. Layout sets context via setContext() for child components
4. Components use getContext() for reactive reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:27:46 +01:00
parent ced7dd7441
commit 30e124e609
87 changed files with 2528 additions and 3136 deletions

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { FileText } from '@manacore/shared-icons';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllDocuments } from '$lib/data/queries';
import type { Document } from '$lib/types';
const allDocs = useAllDocuments();
interface Props {
value: string;
placeholder?: string;
@ -20,7 +22,7 @@
let filteredDocs = $derived(
mentionQuery.trim()
? documentsStore.documents
? (allDocs.value ?? [])
.filter((d) => d.title.toLowerCase().includes(mentionQuery.toLowerCase()))
.slice(0, 6)
: []

View file

@ -0,0 +1,147 @@
/**
* Reactive Queries & Pure Helpers for Context
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
spaceCollection,
documentCollection,
type LocalSpace,
type LocalDocument,
} from './local-store';
import type { Space, Document, DocumentType } from '$lib/types';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalSpace (IndexedDB) to shared Space type. */
export function toSpace(local: LocalSpace): Space {
return {
id: local.id,
name: local.name,
description: local.description ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
settings: local.settings ?? null,
pinned: local.pinned,
prefix: local.prefix,
};
}
/** Convert LocalDocument (IndexedDB) to shared Document type. */
export function toDocument(local: LocalDocument): Document {
return {
id: local.id,
title: local.title,
content: local.content,
type: local.type,
space_id: local.spaceId ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
metadata: local.metadata ?? null,
short_id: local.shortId ?? undefined,
pinned: local.pinned,
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All spaces, sorted by name. Auto-updates on any change. */
export function useAllSpaces() {
return useLiveQueryWithDefault(async () => {
const locals = await spaceCollection.getAll();
return locals.map(toSpace).sort((a, b) => a.name.localeCompare(b.name));
}, [] as Space[]);
}
/** All documents. Auto-updates on any change. */
export function useAllDocuments() {
return useLiveQueryWithDefault(async () => {
const locals = await documentCollection.getAll();
return locals
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
/** Documents for a specific space. Auto-updates on any change. */
export function useSpaceDocuments(spaceId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await documentCollection.getAll({ spaceId });
return locals
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Get pinned spaces from a list. */
export function getPinnedSpaces(spaces: Space[]): Space[] {
return spaces.filter((s) => s.pinned);
}
/** Filter documents by type, search query, and tags. */
export function filterDocuments(
documents: Document[],
options: {
typeFilter?: DocumentType | 'all';
searchQuery?: string;
tagFilter?: string[];
}
): Document[] {
let filtered = documents;
if (options.typeFilter && options.typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === options.typeFilter);
}
if (options.searchQuery?.trim()) {
const q = options.searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (options.tagFilter && options.tagFilter.length > 0) {
filtered = filtered.filter((d) =>
options.tagFilter!.some((tag) => d.metadata?.tags?.includes(tag))
);
}
return filtered;
}
/** Compute document stats from a list. */
export function getDocumentStats(documents: Document[]) {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
}
/** Get all unique tags from documents. */
export function getAllDocumentTags(documents: Document[]): string[] {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
}
/** Find a space by ID. */
export function findSpaceById(spaces: Space[], id: string): Space | undefined {
return spaces.find((s) => s.id === id);
}
/** Find a document by ID. */
export function findDocumentById(documents: Document[], id: string): Document | undefined {
return documents.find((d) => d.id === id);
}

View file

@ -1,25 +1,25 @@
/**
* Documents Store Mutation-Only (Local-First)
*
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes and local filter state.
*/
import type { Document, DocumentType } from '$lib/types';
import { ContextEvents } from '@manacore/shared-utils/analytics';
import * as docsService from '$lib/services/documents';
import { documentCollection, type LocalDocument } from '$lib/data/local-store';
import { toDocument } from '$lib/data/queries';
let documents = $state<Document[]>([]);
let currentDocument = $state<Document | null>(null);
let loading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
// Filter state
// Filter state (UI-only, not persisted)
let searchQuery = $state('');
let typeFilter = $state<DocumentType | 'all'>('all');
let tagFilter = $state<string[]>([]);
export const documentsStore = {
get documents() {
return documents;
},
get currentDocument() {
return currentDocument;
},
get loading() {
return loading;
},
@ -39,45 +39,6 @@ export const documentsStore = {
return tagFilter;
},
get filteredDocuments() {
let filtered = documents;
if (typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === typeFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (tagFilter.length > 0) {
filtered = filtered.filter((d) => tagFilter.some((tag) => d.metadata?.tags?.includes(tag)));
}
return filtered;
},
get allTags() {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
},
get stats() {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
},
setSearchQuery(query: string) {
searchQuery = query;
},
@ -90,30 +51,6 @@ export const documentsStore = {
tagFilter = tags;
},
async load(spaceId?: string) {
loading = true;
error = null;
try {
documents = await docsService.getDocumentsWithPreview(spaceId);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Dokumente';
} finally {
loading = false;
}
},
async loadDocument(id: string) {
loading = true;
error = null;
try {
currentDocument = await docsService.getDocumentById(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden des Dokuments';
} finally {
loading = false;
}
},
async create(
userId: string,
content: string,
@ -122,21 +59,24 @@ export const documentsStore = {
title?: string
) {
saving = true;
error = null;
try {
const result = await docsService.createDocument(
userId,
const newLocal: LocalDocument = {
id: crypto.randomUUID(),
title: title || 'Neues Dokument',
content,
type,
spaceId,
undefined,
title
);
if (result.data) {
documents = [result.data, ...documents];
currentDocument = result.data;
ContextEvents.documentCreated(type);
}
return result;
spaceId: spaceId || null,
pinned: false,
metadata: null,
};
const inserted = await documentCollection.insert(newLocal);
ContextEvents.documentCreated(type);
return { data: toDocument(inserted), error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen';
error = msg;
return { data: null, error: msg };
} finally {
saving = false;
}
@ -144,64 +84,67 @@ export const documentsStore = {
async update(id: string, updates: Partial<Document>) {
saving = true;
error = null;
try {
const result = await docsService.updateDocument(id, updates);
if (result.success) {
documents = documents.map((d) => (d.id === id ? { ...d, ...updates } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, ...updates };
}
}
return result;
const localUpdates: Partial<LocalDocument> = {};
if (updates.title !== undefined) localUpdates.title = updates.title;
if (updates.content !== undefined) localUpdates.content = updates.content!;
if (updates.type !== undefined) localUpdates.type = updates.type;
if (updates.pinned !== undefined) localUpdates.pinned = updates.pinned!;
if (updates.metadata !== undefined) localUpdates.metadata = updates.metadata;
await documentCollection.update(id, localUpdates);
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Aktualisieren';
error = msg;
return { success: false, error: msg };
} finally {
saving = false;
}
},
async delete(id: string) {
const result = await docsService.deleteDocument(id);
if (result.success) {
error = null;
try {
await documentCollection.delete(id);
ContextEvents.documentDeleted();
documents = documents.filter((d) => d.id !== id);
if (currentDocument?.id === id) {
currentDocument = null;
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
error = msg;
return { success: false, error: msg };
}
return result;
},
async togglePinned(id: string) {
const doc = documents.find((d) => d.id === id);
if (!doc) return;
const newPinned = !doc.pinned;
const result = await docsService.toggleDocumentPinned(id, newPinned);
if (result.success) {
async togglePinned(id: string, currentPinned: boolean) {
error = null;
try {
const newPinned = !currentPinned;
await documentCollection.update(id, { pinned: newPinned });
ContextEvents.documentPinned(newPinned);
documents = documents.map((d) => (d.id === id ? { ...d, pinned: newPinned } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, pinned: newPinned };
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Pin-Toggle';
error = msg;
return { success: false, error: msg };
}
return result;
},
async saveTags(id: string, tags: string[]) {
const result = await docsService.saveDocumentTags(id, tags);
if (result.success) {
documents = documents.map((d) =>
d.id === id ? { ...d, metadata: { ...d.metadata, tags } } : d
);
if (currentDocument?.id === id) {
currentDocument = {
...currentDocument,
metadata: { ...currentDocument.metadata, tags },
};
error = null;
try {
const existing = await documentCollection.get(id);
if (existing) {
await documentCollection.update(id, {
metadata: { ...existing.metadata, tags },
});
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Tags';
error = msg;
return { success: false, error: msg };
}
return result;
},
clearCurrent() {
currentDocument = null;
},
};

View file

@ -1,75 +1,90 @@
/**
* Spaces Store Mutation-Only (Local-First)
*
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes (create, update, delete, toggle).
*/
import type { Space } from '$lib/types';
import { ContextEvents } from '@manacore/shared-utils/analytics';
import * as spacesService from '$lib/services/spaces';
import { spaceCollection, type LocalSpace } from '$lib/data/local-store';
import { toSpace } from '$lib/data/queries';
let spaces = $state<Space[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const spacesStore = {
get spaces() {
return spaces;
},
get loading() {
return loading;
},
get error() {
return error;
},
get pinnedSpaces() {
return spaces.filter((s) => s.pinned);
},
async load() {
async create(userId: string, name: string, description?: string) {
loading = true;
error = null;
try {
spaces = await spacesService.getSpaces();
const newLocal: LocalSpace = {
id: crypto.randomUUID(),
name,
description: description || null,
settings: null,
pinned: true,
prefix: name.charAt(0).toUpperCase(),
};
const inserted = await spaceCollection.insert(newLocal);
ContextEvents.spaceCreated();
return { data: toSpace(inserted), error: null };
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Spaces';
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen';
error = msg;
return { data: null, error: msg };
} finally {
loading = false;
}
},
async getById(id: string): Promise<Space | null> {
return spacesService.getSpaceById(id);
},
async create(userId: string, name: string, description?: string) {
const result = await spacesService.createSpace(userId, name, description);
if (result.data) {
spaces = [result.data, ...spaces];
ContextEvents.spaceCreated();
}
return result;
},
async update(id: string, updates: Partial<Space>) {
const result = await spacesService.updateSpace(id, updates);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, ...updates } : s));
error = null;
try {
const localUpdates: Partial<LocalSpace> = {};
if (updates.name !== undefined) localUpdates.name = updates.name;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.pinned !== undefined) localUpdates.pinned = updates.pinned;
if (updates.settings !== undefined) localUpdates.settings = updates.settings;
await spaceCollection.update(id, localUpdates);
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Aktualisieren';
error = msg;
return { success: false, error: msg };
}
return result;
},
async togglePinned(id: string) {
const space = spaces.find((s) => s.id === id);
if (!space) return;
const newPinned = !space.pinned;
const result = await spacesService.toggleSpacePinned(id, newPinned);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, pinned: newPinned } : s));
async togglePinned(id: string, currentPinned: boolean) {
error = null;
try {
await spaceCollection.update(id, { pinned: !currentPinned });
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Pin-Toggle';
error = msg;
return { success: false, error: msg };
}
return result;
},
async delete(id: string) {
const result = await spacesService.deleteSpace(id);
if (result.success) {
spaces = spaces.filter((s) => s.id !== id);
error = null;
try {
await spaceCollection.delete(id);
ContextEvents.spaceDeleted();
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
error = msg;
return { success: false, error: msg };
}
return result;
},
};

View file

@ -15,6 +15,7 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllSpaces, useAllDocuments } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -42,6 +43,10 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live queries: all spaces and documents (reactive, auto-updates on IndexedDB changes)
const allSpaces = useAllSpaces();
const allDocuments = useAllDocuments();
let { children } = $props();
let commandBarOpen = $state(false);
@ -70,7 +75,7 @@
const results: CommandBarItem[] = [];
// Search spaces
spacesStore.spaces
(allSpaces.value ?? [])
.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((s) => {
@ -82,7 +87,7 @@
});
// Search documents
documentsStore.documents
(allDocuments.value ?? [])
.filter((d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((d) => {
@ -243,7 +248,6 @@
if (authStore.isAuthenticated) {
await userSettings.load();
await Promise.all([spacesStore.load(), documentsStore.load()]);
}
}
</script>

View file

@ -1,29 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Folder, FileText, Sparkle, Plus } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllSpaces,
useAllDocuments,
getPinnedSpaces,
getDocumentStats,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let isLoading = $state(true);
let recentDocs = $state<typeof documentsStore.documents>([]);
onMount(async () => {
await spacesStore.load();
await documentsStore.load();
recentDocs = documentsStore.documents.slice(0, 6);
isLoading = false;
});
const allSpaces = useAllSpaces();
const allDocuments = useAllDocuments();
let spaces = $derived(allSpaces.value ?? []);
let documents = $derived(allDocuments.value ?? []);
let pinnedSpaces = $derived(getPinnedSpaces(spaces));
let stats = $derived(getDocumentStats(documents));
let recentDocs = $derived(documents.slice(0, 6));
function handleDeleteDoc(id: string) {
documentsStore.delete(id);
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
</script>
@ -31,97 +35,93 @@
<title>Context - Dashboard</title>
</svelte:head>
{#if isLoading}
<AppLoadingSkeleton />
{:else}
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Context</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Wissensmanagement Hub</p>
</header>
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Context</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Wissensmanagement Hub</p>
</header>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{spacesStore.spaces.length}</div>
<div class="text-xs text-muted-foreground mt-1">Spaces</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{documentsStore.stats.total}</div>
<div class="text-xs text-muted-foreground mt-1">Dokumente</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.totalWords.toLocaleString()}
</div>
<div class="text-xs text-muted-foreground mt-1">Wörter</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.text}/{documentsStore.stats.context}/{documentsStore.stats.prompt}
</div>
<div class="text-xs text-muted-foreground mt-1">Text/Kontext/Prompt</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{spaces.length}</div>
<div class="text-xs text-muted-foreground mt-1">Spaces</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-3 mb-8">
<a href="/spaces" class="btn btn-primary flex items-center gap-2">
<Folder size={16} />
Spaces
</a>
<a href="/documents" class="btn btn-secondary flex items-center gap-2">
<FileText size={16} />
Alle Dokumente
</a>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{stats.total}</div>
<div class="text-xs text-muted-foreground mt-1">Dokumente</div>
</div>
<!-- Pinned Spaces -->
{#if spacesStore.pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="text-lg font-semibold text-foreground mb-4">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each spacesStore.pinnedSpaces as space}
<SpaceCard {space} />
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Zuletzt bearbeitet</h2>
<a href="/documents" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each recentDocs as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
</section>
{:else}
<div class="card p-8 text-center">
<div class="p-4 rounded-full bg-muted inline-block mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch keine Dokumente</h3>
<p class="text-sm text-muted-foreground mb-4">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a href="/spaces" class="btn btn-primary inline-flex items-center gap-2">
<Plus size={16} />
Ersten Space erstellen
</a>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{stats.totalWords.toLocaleString()}
</div>
{/if}
<div class="text-xs text-muted-foreground mt-1">Wörter</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{stats.text}/{stats.context}/{stats.prompt}
</div>
<div class="text-xs text-muted-foreground mt-1">Text/Kontext/Prompt</div>
</div>
</div>
{/if}
<!-- Quick Actions -->
<div class="flex gap-3 mb-8">
<a href="/spaces" class="btn btn-primary flex items-center gap-2">
<Folder size={16} />
Spaces
</a>
<a href="/documents" class="btn btn-secondary flex items-center gap-2">
<FileText size={16} />
Alle Dokumente
</a>
</div>
<!-- Pinned Spaces -->
{#if pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="text-lg font-semibold text-foreground mb-4">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each pinnedSpaces as space}
<SpaceCard {space} />
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Zuletzt bearbeitet</h2>
<a href="/documents" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each recentDocs as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
</section>
{:else}
<div class="card p-8 text-center">
<div class="p-4 rounded-full bg-muted inline-block mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch keine Dokumente</h3>
<p class="text-sm text-muted-foreground mb-4">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a href="/spaces" class="btn btn-primary inline-flex items-center gap-2">
<Plus size={16} />
Ersten Space erstellen
</a>
</div>
{/if}
</div>
<style>
.dashboard {

View file

@ -1,16 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass, FileText } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllDocuments,
filterDocuments,
getDocumentStats,
getAllDocumentTags,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { DocumentType } from '$lib/types';
let deleteTarget = $state<string | null>(null);
const allDocuments = useAllDocuments();
let documents = $derived(allDocuments.value ?? []);
let stats = $derived(getDocumentStats(documents));
let allTags = $derived(getAllDocumentTags(documents));
let filteredDocuments = $derived(
filterDocuments(documents, {
typeFilter: documentsStore.typeFilter,
searchQuery: documentsStore.searchQuery,
tagFilter: documentsStore.tagFilter,
})
);
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
@ -18,10 +35,6 @@
{ value: 'prompt', label: 'Prompt' },
];
onMount(async () => {
await documentsStore.load();
});
async function handleCreateDocument() {
if (!authStore.user?.id) return;
const result = await documentsStore.create(
@ -48,7 +61,8 @@
}
function handleTogglePin(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
</script>
@ -61,7 +75,7 @@
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('documents.title')}</h1>
<p class="text-sm text-muted-foreground mt-1">
{documentsStore.stats.total} Dokumente, {documentsStore.stats.totalWords.toLocaleString()} Wörter
{stats.total} Dokumente, {stats.totalWords.toLocaleString()} Wörter
</p>
</div>
<button class="btn btn-primary flex items-center gap-2" onclick={handleCreateDocument}>
@ -84,13 +98,13 @@
>
{filter.label}
{#if filter.value === 'all'}
<span class="ml-1 opacity-60">{documentsStore.stats.total}</span>
<span class="ml-1 opacity-60">{stats.total}</span>
{:else if filter.value === 'text'}
<span class="ml-1 opacity-60">{documentsStore.stats.text}</span>
<span class="ml-1 opacity-60">{stats.text}</span>
{:else if filter.value === 'context'}
<span class="ml-1 opacity-60">{documentsStore.stats.context}</span>
<span class="ml-1 opacity-60">{stats.context}</span>
{:else if filter.value === 'prompt'}
<span class="ml-1 opacity-60">{documentsStore.stats.prompt}</span>
<span class="ml-1 opacity-60">{stats.prompt}</span>
{/if}
</button>
{/each}
@ -112,9 +126,9 @@
</div>
<!-- Tags filter -->
{#if documentsStore.allTags.length > 0}
{#if allTags.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each documentsStore.allTags as tag}
{#each allTags as tag}
<button
class="text-xs px-2 py-1 rounded-full transition-colors"
class:bg-primary={documentsStore.tagFilter.includes(tag)}
@ -139,9 +153,9 @@
<!-- Document list -->
{#if documentsStore.loading}
<div class="text-center py-12 text-muted-foreground">Lade Dokumente...</div>
{:else if documentsStore.filteredDocuments.length > 0}
{:else if filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
{#each filteredDocuments as doc}
<DocumentCard document={doc} onTogglePin={handleTogglePin} onDelete={handleDeleteClick} />
{/each}
</div>

View file

@ -5,6 +5,7 @@
import { ArrowLeft, Trash, Sparkle } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllDocuments, findDocumentById } from '$lib/data/queries';
import { tokensStore } from '$lib/stores/tokens.svelte';
import DocumentEditor from '$lib/components/DocumentEditor.svelte';
import AIToolbar from '$lib/components/AIToolbar.svelte';
@ -12,32 +13,19 @@
import type { Document, DocumentType } from '$lib/types';
import type { InsertionMode } from '$lib/services/ai';
let loading = $state(true);
let showDeleteConfirm = $state(false);
let showAI = $state(false);
let docId = $derived($page.params.id || '');
let doc = $derived(documentsStore.currentDocument);
const allDocuments = useAllDocuments();
let doc = $derived(findDocumentById(allDocuments.value ?? [], docId) ?? null);
onMount(() => {
const init = async () => {
await documentsStore.loadDocument(docId);
if (!documentsStore.currentDocument) {
goto('/documents');
return;
}
loading = false;
// Load token balance
if (authStore.user?.id) {
tokensStore.loadBalance(authStore.user.id);
}
};
init();
return () => {
documentsStore.clearCurrent();
};
// Load token balance
if (authStore.user?.id) {
tokensStore.loadBalance(authStore.user.id);
}
});
function handleSave(updates: Partial<Document>) {
@ -64,9 +52,7 @@
} else if (mode === 'replace') {
documentsStore.update(docId, { content: text });
}
// Reload document to get updated content
documentsStore.loadDocument(docId);
// liveQuery will automatically update the doc
}
async function handleDelete() {
@ -84,7 +70,7 @@
</svelte:head>
<div class="mx-auto max-w-4xl pb-48">
{#if loading}
{#if !doc && !allDocuments.error}
<div class="text-center py-12 text-muted-foreground">Lade Dokument...</div>
{:else if doc}
<!-- Breadcrumb -->

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { useAllSpaces } from '$lib/data/queries';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import CreateSpaceModal from '$lib/components/CreateSpaceModal.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@ -15,20 +15,19 @@
let deleteTarget = $state<string | null>(null);
let editTarget = $state<Space | null>(null);
const allSpaces = useAllSpaces();
let spaces = $derived(allSpaces.value ?? []);
let filteredSpaces = $derived(
searchQuery.trim()
? spacesStore.spaces.filter(
? spaces.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: spacesStore.spaces
: spaces
);
onMount(async () => {
await spacesStore.load();
});
async function handleCreate(name: string, description: string) {
if (!authStore.user?.id) return;
creating = true;
@ -38,7 +37,8 @@
}
function handleTogglePin(id: string) {
spacesStore.togglePinned(id);
const space = spaces.find((s) => s.id === id);
spacesStore.togglePinned(id, space?.pinned ?? false);
}
function handleDeleteClick(id: string) {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import {
@ -14,13 +13,18 @@
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllSpaces,
useSpaceDocuments,
filterDocuments,
getDocumentStats,
findSpaceById,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import BatchCreateModal from '$lib/components/BatchCreateModal.svelte';
import type { Space, DocumentType } from '$lib/types';
let space = $state<Space | null>(null);
let loading = $state(true);
let editingName = $state(false);
let editName = $state('');
let editDescription = $state('');
@ -30,16 +34,24 @@
let spaceId = $derived($page.params.id || '');
onMount(async () => {
space = await spacesStore.getById(spaceId);
if (!space) {
goto('/spaces');
return;
const allSpaces = useAllSpaces();
const spaceDocs = useSpaceDocuments(spaceId);
let space = $derived(findSpaceById(allSpaces.value ?? [], spaceId) ?? null);
let documents = $derived(spaceDocs.value ?? []);
let stats = $derived(getDocumentStats(documents));
let filteredDocuments = $derived(
filterDocuments(documents, {
typeFilter: documentsStore.typeFilter,
searchQuery: documentsStore.searchQuery,
})
);
// Keep editName/editDescription in sync with space
$effect(() => {
if (space && !editingName) {
editName = space.name;
editDescription = space.description || '';
}
editName = space.name;
editDescription = space.description || '';
await documentsStore.load(spaceId);
loading = false;
});
async function handleCreateDocument() {
@ -90,7 +102,8 @@
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
async function handleBatchCreate(items: { title: string; type: DocumentType }[]) {
@ -107,7 +120,6 @@
}
batchCreating = false;
showBatchCreate = false;
await documentsStore.load(spaceId);
}
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
@ -133,7 +145,7 @@
<span class="text-foreground font-medium">{space?.name || '...'}</span>
</div>
{#if loading}
{#if !space && !allSpaces.error}
<div class="text-center py-12 text-muted-foreground">Lade...</div>
{:else if space}
<!-- Space Header -->
@ -168,8 +180,8 @@
<p class="text-sm text-muted-foreground mt-1">{space.description}</p>
{/if}
<div class="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>{documentsStore.stats.total} Dokumente</span>
<span>{documentsStore.stats.totalWords.toLocaleString()} Wörter</span>
<span>{stats.total} Dokumente</span>
<span>{stats.totalWords.toLocaleString()} Wörter</span>
</div>
</div>
<button
@ -233,9 +245,9 @@
</div>
<!-- Documents -->
{#if documentsStore.filteredDocuments.length > 0}
{#if filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
{#each filteredDocuments as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}