diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index ec98ea6ff..48aa5aeef 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -693,7 +693,9 @@ registerApp({ icon: BookOpen, views: { list: { load: () => import('$lib/modules/guides/ListView.svelte') }, + detail: { load: () => import('$lib/modules/guides/views/DetailView.svelte') }, }, + paramKey: 'guideId', }); registerApp({ diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index a3bcce3fa..855ec67c4 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -292,6 +292,8 @@ export const ENCRYPTION_REGISTRY: Record = { // free-form text and the whole point of having a vault. Indexed // columns (isPinned, order) stay plaintext for sort. playgroundSnippets: { enabled: true, fields: ['name', 'systemPrompt'] }, + playgroundConversations: { enabled: true, fields: ['title', 'systemPrompt'] }, + playgroundMessages: { enabled: true, fields: ['content'] }, // ─── News ──────────────────────────────────────────────── // Saved articles are reading-behavior data (sensitive). The body @@ -380,6 +382,11 @@ export const ENCRYPTION_REGISTRY: Record = { // sourceId, parentBlockId, recurrenceDate) all stay plaintext — // the calendar query layer needs them for range scans. timeBlocks: { enabled: true, fields: ['title', 'description'] }, + + // ─── Guides ────────────────────────────────────────────── + guides: { enabled: true, fields: ['title', 'description'] }, + sections: { enabled: true, fields: ['title', 'content'] }, + steps: { enabled: true, fields: ['title', 'content'] }, }; /** diff --git a/apps/mana/apps/web/src/lib/modules/guides/ListView.svelte b/apps/mana/apps/web/src/lib/modules/guides/ListView.svelte index 036bf42cc..9ed17b157 100644 --- a/apps/mana/apps/web/src/lib/modules/guides/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/guides/ListView.svelte @@ -1,9 +1,37 @@ -
- + g.id} emptyTitle="Keine Guides gefunden"> + {#snippet toolbar()} + + -
- {#each categories as cat (cat.id)} - - {/each} -
- -
- {#each filtered as guide (guide.id)} - {@const meta = GUIDE_CATEGORIES[guide.category]} -
-
- - {meta.label} - {guide.estimatedMinutes} min -
-

{guide.title}

-

{guide.description}

-

- {difficultyLabel[guide.difficulty]} -

+ +
+
+ {#each categories as cat (cat.id)} + + {/each}
- {:else} -

Keine Guides gefunden.

- {/each} -
-
+ +
+ + {#if creating} +
+ + + +
+ {/if} + {/snippet} + + {#snippet header()} + {guides.length} Guides + {/snippet} + + {#snippet item(guide)} + {@const meta = GUIDE_CATEGORIES[guide.category]} + {@const run = runs.get(guide.id)} + {@const totalSteps = stepCounts.get(guide.id) ?? 0} + {@const progress = getStepProgress(run ?? null, totalSteps)} + + {/snippet} +
diff --git a/apps/mana/apps/web/src/lib/modules/guides/collections.ts b/apps/mana/apps/web/src/lib/modules/guides/collections.ts new file mode 100644 index 000000000..3213d1cbc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/guides/collections.ts @@ -0,0 +1,355 @@ +/** + * Guides module — collection accessors and guest seed data. + * + * The 6 starter guides ship as seed data so new users see content + * immediately. Each guide includes sections and steps that can be + * worked through interactively. + */ + +import { db } from '$lib/data/database'; +import type { LocalGuide, LocalSection, LocalStep, LocalGuideCollection, LocalRun } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const guideTable = db.table('guides'); +export const sectionTable = db.table('sections'); +export const stepTable = db.table('steps'); +export const guideCollectionTable = db.table('guideCollections'); +export const runTable = db.table('runs'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const GUIDES_GUEST_SEED = { + guides: [ + { + id: 'guide-welcome', + title: 'Willkommen bei Mana', + description: 'Ein Überblick über das Mana-Ökosystem und seine Apps.', + category: 'getting-started' as const, + difficulty: 'beginner' as const, + estimatedMinutes: 5, + collectionId: null, + isPublished: true, + order: 0, + }, + { + id: 'guide-local-first', + title: 'Offline-First verstehen', + description: 'Wie Mana lokal arbeitet und im Hintergrund synchronisiert.', + category: 'getting-started' as const, + difficulty: 'beginner' as const, + estimatedMinutes: 8, + collectionId: null, + isPublished: true, + order: 1, + }, + { + id: 'guide-keyboard', + title: 'Tastaturkürzel', + description: 'Navigiere schneller mit Tastaturkürzeln durch alle Apps.', + category: 'productivity' as const, + difficulty: 'beginner' as const, + estimatedMinutes: 5, + collectionId: null, + isPublished: true, + order: 2, + }, + { + id: 'guide-todo', + title: 'Todo-Workflows', + description: 'Projekte, Labels und Fokus-Modus effektiv nutzen.', + category: 'productivity' as const, + difficulty: 'intermediate' as const, + estimatedMinutes: 10, + collectionId: null, + isPublished: true, + order: 3, + }, + { + id: 'guide-ai', + title: 'KI-Funktionen nutzen', + description: 'Chat, Playground und KI-gestützte Features in Mana.', + category: 'advanced' as const, + difficulty: 'intermediate' as const, + estimatedMinutes: 12, + collectionId: null, + isPublished: true, + order: 4, + }, + { + id: 'guide-sync', + title: 'Sync einrichten', + description: 'Geräteübergreifende Synchronisation konfigurieren.', + category: 'integrations' as const, + difficulty: 'intermediate' as const, + estimatedMinutes: 8, + collectionId: null, + isPublished: true, + order: 5, + }, + ] satisfies LocalGuide[], + + sections: [ + // ── Welcome guide ───────────────────────────────── + { + id: 'sec-welcome-1', + guideId: 'guide-welcome', + title: 'Was ist Mana?', + content: null, + order: 0, + }, + { + id: 'sec-welcome-2', + guideId: 'guide-welcome', + title: 'Deine ersten Schritte', + content: null, + order: 1, + }, + // ── Local-first guide ───────────────────────────── + { + id: 'sec-local-1', + guideId: 'guide-local-first', + title: 'Das Prinzip', + content: null, + order: 0, + }, + { + id: 'sec-local-2', + guideId: 'guide-local-first', + title: 'Sync & Konflikte', + content: null, + order: 1, + }, + // ── Keyboard guide ──────────────────────────────── + { id: 'sec-kb-1', guideId: 'guide-keyboard', title: 'Navigation', content: null, order: 0 }, + { + id: 'sec-kb-2', + guideId: 'guide-keyboard', + title: 'Schnellaktionen', + content: null, + order: 1, + }, + // ── Todo guide ──────────────────────────────────── + { id: 'sec-todo-1', guideId: 'guide-todo', title: 'Projekte anlegen', content: null, order: 0 }, + { id: 'sec-todo-2', guideId: 'guide-todo', title: 'Labels & Filter', content: null, order: 1 }, + { id: 'sec-todo-3', guideId: 'guide-todo', title: 'Fokus-Modus', content: null, order: 2 }, + // ── AI guide ────────────────────────────────────── + { id: 'sec-ai-1', guideId: 'guide-ai', title: 'Chat nutzen', content: null, order: 0 }, + { id: 'sec-ai-2', guideId: 'guide-ai', title: 'Playground', content: null, order: 1 }, + { + id: 'sec-ai-3', + guideId: 'guide-ai', + title: 'KI in anderen Modulen', + content: null, + order: 2, + }, + // ── Sync guide ──────────────────────────────────── + { + id: 'sec-sync-1', + guideId: 'guide-sync', + title: 'Account verbinden', + content: null, + order: 0, + }, + { + id: 'sec-sync-2', + guideId: 'guide-sync', + title: 'Geräte hinzufügen', + content: null, + order: 1, + }, + ] satisfies LocalSection[], + + steps: [ + // ── Welcome > Was ist Mana? ─────────────────────── + { + id: 'step-w1-1', + guideId: 'guide-welcome', + sectionId: 'sec-welcome-1', + title: 'Mana öffnen und Dashboard ansehen', + content: 'Öffne mana.how und sieh dir das Dashboard an. Hier findest du alle deine Module.', + order: 0, + }, + { + id: 'step-w1-2', + guideId: 'guide-welcome', + sectionId: 'sec-welcome-1', + title: 'Module entdecken', + content: + 'Klicke auf verschiedene Module in der Seitenleiste, um zu sehen, was Mana alles kann.', + order: 1, + }, + // ── Welcome > Erste Schritte ────────────────────── + { + id: 'step-w2-1', + guideId: 'guide-welcome', + sectionId: 'sec-welcome-2', + title: 'Erste Notiz erstellen', + content: 'Öffne das Notes-Modul und erstelle deine erste Notiz.', + order: 0, + }, + { + id: 'step-w2-2', + guideId: 'guide-welcome', + sectionId: 'sec-welcome-2', + title: 'Erste Aufgabe anlegen', + content: 'Wechsle zu Todo und lege deine erste Aufgabe an.', + order: 1, + }, + // ── Local-first > Das Prinzip ───────────────────── + { + id: 'step-l1-1', + guideId: 'guide-local-first', + sectionId: 'sec-local-1', + title: 'Offline-Modus testen', + content: 'Schalte dein WLAN aus und erstelle eine Notiz. Sie wird gespeichert!', + order: 0, + }, + { + id: 'step-l1-2', + guideId: 'guide-local-first', + sectionId: 'sec-local-1', + title: 'IndexedDB inspizieren', + content: + 'Öffne die Browser DevTools → Application → IndexedDB → mana, um deine lokalen Daten zu sehen.', + order: 1, + }, + // ── Local-first > Sync ──────────────────────────── + { + id: 'step-l2-1', + guideId: 'guide-local-first', + sectionId: 'sec-local-2', + title: 'WLAN wieder aktivieren', + content: + 'Schalte WLAN ein und beobachte, wie deine Offline-Änderungen synchronisiert werden.', + order: 0, + }, + // ── Keyboard > Navigation ───────────────────────── + { + id: 'step-kb1-1', + guideId: 'guide-keyboard', + sectionId: 'sec-kb-1', + title: 'Cmd+K ausprobieren', + content: 'Drücke Cmd+K (oder Ctrl+K), um die Schnellsuche zu öffnen.', + order: 0, + }, + { + id: 'step-kb1-2', + guideId: 'guide-keyboard', + sectionId: 'sec-kb-1', + title: 'Zwischen Modulen wechseln', + content: 'Nutze die Schnellsuche, um direkt zu einem Modul zu springen.', + order: 1, + }, + // ── Keyboard > Schnellaktionen ──────────────────── + { + id: 'step-kb2-1', + guideId: 'guide-keyboard', + sectionId: 'sec-kb-2', + title: 'Schnell-Todo anlegen', + content: 'Drücke Cmd+Shift+T, um sofort eine neue Aufgabe zu erstellen.', + order: 0, + }, + // ── Todo > Projekte ─────────────────────────────── + { + id: 'step-td1-1', + guideId: 'guide-todo', + sectionId: 'sec-todo-1', + title: 'Neues Projekt erstellen', + content: 'Gehe zu Todo → Projekte und erstelle ein neues Projekt.', + order: 0, + }, + { + id: 'step-td1-2', + guideId: 'guide-todo', + sectionId: 'sec-todo-1', + title: 'Aufgaben zum Projekt hinzufügen', + content: 'Füge mindestens 3 Aufgaben zum Projekt hinzu.', + order: 1, + }, + // ── Todo > Labels ───────────────────────────────── + { + id: 'step-td2-1', + guideId: 'guide-todo', + sectionId: 'sec-todo-2', + title: 'Labels erstellen', + content: 'Erstelle Labels wie "Dringend", "Idee" oder "Warten auf".', + order: 0, + }, + // ── Todo > Fokus ────────────────────────────────── + { + id: 'step-td3-1', + guideId: 'guide-todo', + sectionId: 'sec-todo-3', + title: 'Fokus-Modus starten', + content: 'Aktiviere den Fokus-Modus, um dich auf eine Aufgabe zu konzentrieren.', + order: 0, + }, + // ── AI > Chat ───────────────────────────────────── + { + id: 'step-ai1-1', + guideId: 'guide-ai', + sectionId: 'sec-ai-1', + title: 'Chat öffnen', + content: 'Öffne das Chat-Modul und starte eine Konversation.', + order: 0, + }, + { + id: 'step-ai1-2', + guideId: 'guide-ai', + sectionId: 'sec-ai-1', + title: 'Eine Frage stellen', + content: 'Frage die KI etwas über deine Notizen oder Aufgaben.', + order: 1, + }, + // ── AI > Playground ─────────────────────────────── + { + id: 'step-ai2-1', + guideId: 'guide-ai', + sectionId: 'sec-ai-2', + title: 'Playground öffnen', + content: 'Wechsle zum Playground-Modul, um verschiedene KI-Modelle auszuprobieren.', + order: 0, + }, + // ── AI > Andere Module ──────────────────────────── + { + id: 'step-ai3-1', + guideId: 'guide-ai', + sectionId: 'sec-ai-3', + title: 'KI in NutriPhi testen', + content: 'Fotografiere eine Mahlzeit in NutriPhi und lass die KI die Nährwerte erkennen.', + order: 0, + }, + // ── Sync > Account ──────────────────────────────── + { + id: 'step-sy1-1', + guideId: 'guide-sync', + sectionId: 'sec-sync-1', + title: 'Account erstellen', + content: + 'Gehe zu Einstellungen → Profil und erstelle einen Account, falls noch nicht geschehen.', + order: 0, + }, + // ── Sync > Geräte ───────────────────────────────── + { + id: 'step-sy2-1', + guideId: 'guide-sync', + sectionId: 'sec-sync-2', + title: 'Auf zweitem Gerät anmelden', + content: 'Öffne mana.how auf einem zweiten Gerät und melde dich mit demselben Account an.', + order: 0, + }, + { + id: 'step-sy2-2', + guideId: 'guide-sync', + sectionId: 'sec-sync-2', + title: 'Sync prüfen', + content: 'Erstelle auf einem Gerät eine Notiz und prüfe, ob sie auf dem anderen erscheint.', + order: 1, + }, + ] satisfies LocalStep[], + + runs: [] as LocalRun[], + guideCollections: [] as LocalGuideCollection[], + guideTags: [] as Array>, +}; diff --git a/apps/mana/apps/web/src/lib/modules/guides/index.ts b/apps/mana/apps/web/src/lib/modules/guides/index.ts index 12a168211..3bd04bdc0 100644 --- a/apps/mana/apps/web/src/lib/modules/guides/index.ts +++ b/apps/mana/apps/web/src/lib/modules/guides/index.ts @@ -1,75 +1,23 @@ /** * Guides module — barrel exports. * - * Interactive guides and tutorials for the Mana ecosystem. - * No local-first collections needed yet (static content). + * Interactive step-by-step guides with sections, steps, and run tracking. + * Types, queries, and stores are the canonical imports; this file just + * re-exports for convenience. */ -export interface Guide { - id: string; - title: string; - description: string; - category: GuideCategory; - difficulty: 'beginner' | 'intermediate' | 'advanced'; - estimatedMinutes: number; -} +export type { + Guide, + Section, + Step, + Run, + GuideCategory, + GuideDifficulty, + LocalGuide, + LocalSection, + LocalStep, + LocalRun, +} from './types'; -export type GuideCategory = 'getting-started' | 'productivity' | 'advanced' | 'integrations'; - -export const GUIDE_CATEGORIES: Record = { - 'getting-started': { label: 'Erste Schritte', color: 'bg-emerald-500' }, - productivity: { label: 'Produktivität', color: 'bg-blue-500' }, - advanced: { label: 'Fortgeschritten', color: 'bg-violet-500' }, - integrations: { label: 'Integrationen', color: 'bg-amber-500' }, -}; - -export const GUIDES: Guide[] = [ - { - id: 'welcome', - title: 'Willkommen bei Mana', - description: 'Ein Überblick über das Mana-Ökosystem und seine Apps.', - category: 'getting-started', - difficulty: 'beginner', - estimatedMinutes: 5, - }, - { - id: 'local-first', - title: 'Offline-First verstehen', - description: 'Wie Mana lokal arbeitet und im Hintergrund synchronisiert.', - category: 'getting-started', - difficulty: 'beginner', - estimatedMinutes: 8, - }, - { - id: 'keyboard-shortcuts', - title: 'Tastaturkürzel', - description: 'Navigiere schneller mit Tastaturkürzeln durch alle Apps.', - category: 'productivity', - difficulty: 'beginner', - estimatedMinutes: 5, - }, - { - id: 'todo-workflows', - title: 'Todo-Workflows', - description: 'Projekte, Labels und Fokus-Modus effektiv nutzen.', - category: 'productivity', - difficulty: 'intermediate', - estimatedMinutes: 10, - }, - { - id: 'ai-features', - title: 'KI-Funktionen nutzen', - description: 'Chat, Playground und KI-gestützte Features in Mana.', - category: 'advanced', - difficulty: 'intermediate', - estimatedMinutes: 12, - }, - { - id: 'sync-setup', - title: 'Sync einrichten', - description: 'Geräteübergreifende Synchronisation konfigurieren.', - category: 'integrations', - difficulty: 'intermediate', - estimatedMinutes: 8, - }, -]; +export { GUIDE_CATEGORIES, DIFFICULTY_LABELS } from './types'; +export { guideTable, sectionTable, stepTable, runTable, GUIDES_GUEST_SEED } from './collections'; diff --git a/apps/mana/apps/web/src/lib/modules/guides/queries.ts b/apps/mana/apps/web/src/lib/modules/guides/queries.ts new file mode 100644 index 000000000..b7829d3d8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/guides/queries.ts @@ -0,0 +1,167 @@ +/** + * Reactive Queries & Pure Helpers for Guides module. + * + * Reads from IndexedDB via Dexie liveQuery. Decrypts title/description/ + * content fields on the fly before handing to the UI. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import type { + LocalGuide, + LocalSection, + LocalStep, + LocalRun, + Guide, + Section, + Step, + Run, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toGuide(local: LocalGuide): Guide { + return { + id: local.id, + title: local.title, + description: local.description, + category: local.category, + difficulty: local.difficulty, + estimatedMinutes: local.estimatedMinutes, + collectionId: local.collectionId, + isPublished: local.isPublished, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toSection(local: LocalSection): Section { + return { + id: local.id, + guideId: local.guideId, + title: local.title, + content: local.content, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toStep(local: LocalStep): Step { + return { + id: local.id, + guideId: local.guideId, + sectionId: local.sectionId, + title: local.title, + content: local.content, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toRun(local: LocalRun): Run { + return { + id: local.id, + guideId: local.guideId, + startedAt: local.startedAt, + completedAt: local.completedAt, + completedStepIds: local.completedStepIds, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllGuides() { + return useLiveQueryWithDefault(async () => { + const all = await db.table('guides').toArray(); + const visible = all.filter((g) => !g.deletedAt); + const decrypted = await decryptRecords('guides', visible); + return decrypted.map(toGuide).sort((a, b) => a.order - b.order); + }, [] as Guide[]); +} + +export function useGuide(id: () => string) { + return useLiveQueryWithDefault( + async () => { + const guideId = id(); + if (!guideId) return null; + const local = await db.table('guides').get(guideId); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('guides', [local]); + return decrypted ? toGuide(decrypted) : null; + }, + null as Guide | null + ); +} + +export function useSections(guideId: () => string) { + return useLiveQueryWithDefault(async () => { + const gid = guideId(); + if (!gid) return []; + const all = await db.table('sections').where('guideId').equals(gid).toArray(); + const visible = all.filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('sections', visible); + return decrypted.map(toSection).sort((a, b) => a.order - b.order); + }, [] as Section[]); +} + +export function useSteps(guideId: () => string) { + return useLiveQueryWithDefault(async () => { + const gid = guideId(); + if (!gid) return []; + const all = await db.table('steps').where('guideId').equals(gid).toArray(); + const visible = all.filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('steps', visible); + return decrypted.map(toStep).sort((a, b) => a.order - b.order); + }, [] as Step[]); +} + +export function useLatestRun(guideId: () => string) { + return useLiveQueryWithDefault( + async () => { + const gid = guideId(); + if (!gid) return null; + const all = await db.table('runs').where('guideId').equals(gid).toArray(); + const visible = all.filter((r) => !r.deletedAt); + if (visible.length === 0) return null; + visible.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + return toRun(visible[0]); + }, + null as Run | null + ); +} + +export function useRunsByGuide() { + return useLiveQueryWithDefault(async () => { + const all = await db.table('runs').toArray(); + const visible = all.filter((r) => !r.deletedAt); + const map = new Map(); + // Keep only the latest run per guide + for (const r of visible.sort((a, b) => b.startedAt.localeCompare(a.startedAt))) { + if (!map.has(r.guideId)) { + map.set(r.guideId, toRun(r)); + } + } + return map; + }, new Map()); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +export function searchGuides(guides: Guide[], query: string): Guide[] { + if (!query.trim()) return guides; + const q = query.toLowerCase(); + return guides.filter( + (g) => g.title.toLowerCase().includes(q) || g.description.toLowerCase().includes(q) + ); +} + +export function getStepProgress(run: Run | null, totalSteps: number): number { + if (!run || totalSteps === 0) return 0; + return Math.round((run.completedStepIds.length / totalSteps) * 100); +} diff --git a/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts b/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts new file mode 100644 index 000000000..1f0cc9423 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts @@ -0,0 +1,195 @@ +/** + * Guides Store — Mutation-Only Service + * + * CRUD for guides, sections, steps, and run tracking. + * All user-typed text fields (title, description, content) are encrypted + * before hitting Dexie. + */ + +import { guideTable, sectionTable, stepTable, runTable } from '../collections'; +import { toGuide, toSection, toStep, toRun } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import type { + LocalGuide, + LocalSection, + LocalStep, + LocalRun, + Guide, + Section, + Step, + Run, + CreateGuideDto, + UpdateGuideDto, + CreateSectionDto, + UpdateSectionDto, + CreateStepDto, + UpdateStepDto, +} from '../types'; + +export const guidesStore = { + // ─── Guides ────────────────────────────────────────── + + async createGuide(dto: CreateGuideDto): Promise { + const existing = await guideTable.toArray(); + const order = existing.filter((g) => !g.deletedAt).length; + + const newLocal: LocalGuide = { + id: crypto.randomUUID(), + title: dto.title, + description: dto.description ?? '', + category: dto.category ?? 'getting-started', + difficulty: dto.difficulty ?? 'beginner', + estimatedMinutes: dto.estimatedMinutes ?? 5, + collectionId: dto.collectionId ?? null, + isPublished: false, + order, + }; + const snapshot = toGuide({ ...newLocal }); + await encryptRecord('guides', newLocal); + await guideTable.add(newLocal); + return snapshot; + }, + + async updateGuide(id: string, dto: UpdateGuideDto): Promise { + const updates: Record = { updatedAt: new Date().toISOString() }; + if (dto.title !== undefined) updates.title = dto.title; + if (dto.description !== undefined) updates.description = dto.description; + if (dto.category !== undefined) updates.category = dto.category; + if (dto.difficulty !== undefined) updates.difficulty = dto.difficulty; + if (dto.estimatedMinutes !== undefined) updates.estimatedMinutes = dto.estimatedMinutes; + if (dto.collectionId !== undefined) updates.collectionId = dto.collectionId; + if (dto.isPublished !== undefined) updates.isPublished = dto.isPublished; + await encryptRecord('guides', updates); + await guideTable.update(id, updates); + }, + + async deleteGuide(id: string): Promise { + const now = new Date().toISOString(); + // Cascade: soft-delete sections, steps, and runs + const sections = await sectionTable.where('guideId').equals(id).toArray(); + for (const s of sections) { + await sectionTable.update(s.id, { deletedAt: now, updatedAt: now }); + } + const steps = await stepTable.where('guideId').equals(id).toArray(); + for (const s of steps) { + await stepTable.update(s.id, { deletedAt: now, updatedAt: now }); + } + const runs = await runTable.where('guideId').equals(id).toArray(); + for (const r of runs) { + await runTable.update(r.id, { deletedAt: now, updatedAt: now }); + } + await guideTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + // ─── Sections ──────────────────────────────────────── + + async createSection(dto: CreateSectionDto): Promise
{ + const existing = await sectionTable.where('guideId').equals(dto.guideId).toArray(); + const order = existing.filter((s) => !s.deletedAt).length; + + const newLocal: LocalSection = { + id: crypto.randomUUID(), + guideId: dto.guideId, + title: dto.title, + content: dto.content ?? null, + order, + }; + const snapshot = toSection({ ...newLocal }); + await encryptRecord('sections', newLocal); + await sectionTable.add(newLocal); + return snapshot; + }, + + async updateSection(id: string, dto: UpdateSectionDto): Promise { + const updates: Record = { updatedAt: new Date().toISOString() }; + if (dto.title !== undefined) updates.title = dto.title; + if (dto.content !== undefined) updates.content = dto.content; + await encryptRecord('sections', updates); + await sectionTable.update(id, updates); + }, + + async deleteSection(id: string): Promise { + const now = new Date().toISOString(); + await sectionTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + // ─── Steps ─────────────────────────────────────────── + + async createStep(dto: CreateStepDto): Promise { + const existing = await stepTable.where('guideId').equals(dto.guideId).toArray(); + const order = existing.filter((s) => !s.deletedAt).length; + + const newLocal: LocalStep = { + id: crypto.randomUUID(), + guideId: dto.guideId, + sectionId: dto.sectionId ?? null, + title: dto.title, + content: dto.content ?? null, + order, + }; + const snapshot = toStep({ ...newLocal }); + await encryptRecord('steps', newLocal); + await stepTable.add(newLocal); + return snapshot; + }, + + async updateStep(id: string, dto: UpdateStepDto): Promise { + const updates: Record = { updatedAt: new Date().toISOString() }; + if (dto.title !== undefined) updates.title = dto.title; + if (dto.content !== undefined) updates.content = dto.content; + if (dto.sectionId !== undefined) updates.sectionId = dto.sectionId; + await encryptRecord('steps', updates); + await stepTable.update(id, updates); + }, + + async deleteStep(id: string): Promise { + const now = new Date().toISOString(); + await stepTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + // ─── Runs (Progress Tracking) ──────────────────────── + + async startRun(guideId: string): Promise { + const newLocal: LocalRun = { + id: crypto.randomUUID(), + guideId, + startedAt: new Date().toISOString(), + completedAt: null, + completedStepIds: [], + }; + const snapshot = toRun({ ...newLocal }); + await runTable.add(newLocal); + return snapshot; + }, + + async completeStep(runId: string, stepId: string): Promise { + const run = await runTable.get(runId); + if (!run) return; + if (run.completedStepIds.includes(stepId)) return; + await runTable.update(runId, { + completedStepIds: [...run.completedStepIds, stepId], + updatedAt: new Date().toISOString(), + }); + }, + + async uncompleteStep(runId: string, stepId: string): Promise { + const run = await runTable.get(runId); + if (!run) return; + await runTable.update(runId, { + completedStepIds: run.completedStepIds.filter((id) => id !== stepId), + updatedAt: new Date().toISOString(), + }); + }, + + async completeRun(runId: string): Promise { + await runTable.update(runId, { + completedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async deleteRun(id: string): Promise { + const now = new Date().toISOString(); + await runTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/guides/types.ts b/apps/mana/apps/web/src/lib/modules/guides/types.ts new file mode 100644 index 000000000..137435702 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/guides/types.ts @@ -0,0 +1,161 @@ +/** + * Guides module types. + * + * Interactive step-by-step guides with sections, steps, and run tracking. + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Guide Categories & Difficulty ──────────────────────── + +export type GuideCategory = 'getting-started' | 'productivity' | 'advanced' | 'integrations'; +export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced'; + +export const GUIDE_CATEGORIES: Record = { + 'getting-started': { label: 'Erste Schritte', color: 'bg-emerald-500' }, + productivity: { label: 'Produktivität', color: 'bg-blue-500' }, + advanced: { label: 'Fortgeschritten', color: 'bg-violet-500' }, + integrations: { label: 'Integrationen', color: 'bg-amber-500' }, +}; + +export const DIFFICULTY_LABELS: Record = { + beginner: 'Einsteiger', + intermediate: 'Fortgeschritten', + advanced: 'Profi', +}; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalGuide extends BaseRecord { + title: string; + description: string; + category: GuideCategory; + difficulty: GuideDifficulty; + estimatedMinutes: number; + collectionId: string | null; + isPublished: boolean; + order: number; +} + +export interface LocalSection extends BaseRecord { + guideId: string; + title: string; + content: string | null; + order: number; +} + +export interface LocalStep extends BaseRecord { + guideId: string; + sectionId: string | null; + title: string; + content: string | null; + order: number; +} + +export interface LocalGuideCollection extends BaseRecord { + name: string; + description: string | null; + color: string; + icon: string; + isDefault: boolean; + sortOrder: number; +} + +export interface LocalRun extends BaseRecord { + guideId: string; + startedAt: string; + completedAt: string | null; + completedStepIds: string[]; +} + +// ─── Domain Types (UI-facing) ───────────────────────────── + +export interface Guide { + id: string; + title: string; + description: string; + category: GuideCategory; + difficulty: GuideDifficulty; + estimatedMinutes: number; + collectionId: string | null; + isPublished: boolean; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface Section { + id: string; + guideId: string; + title: string; + content: string | null; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface Step { + id: string; + guideId: string; + sectionId: string | null; + title: string; + content: string | null; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface Run { + id: string; + guideId: string; + startedAt: string; + completedAt: string | null; + completedStepIds: string[]; + createdAt: string; + updatedAt: string; +} + +// ─── DTOs ───────────────────────────────────────────────── + +export interface CreateGuideDto { + title: string; + description?: string; + category?: GuideCategory; + difficulty?: GuideDifficulty; + estimatedMinutes?: number; + collectionId?: string; +} + +export interface UpdateGuideDto { + title?: string; + description?: string; + category?: GuideCategory; + difficulty?: GuideDifficulty; + estimatedMinutes?: number; + collectionId?: string; + isPublished?: boolean; +} + +export interface CreateSectionDto { + guideId: string; + title: string; + content?: string; +} + +export interface UpdateSectionDto { + title?: string; + content?: string; +} + +export interface CreateStepDto { + guideId: string; + sectionId?: string; + title: string; + content?: string; +} + +export interface UpdateStepDto { + title?: string; + content?: string; + sectionId?: string; +} diff --git a/apps/mana/apps/web/src/lib/modules/guides/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/guides/views/DetailView.svelte new file mode 100644 index 000000000..f68ab8c5e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/guides/views/DetailView.svelte @@ -0,0 +1,582 @@ + + + +{#if !guide} +
Lade Guide...
+{:else} +
+ +
+
+ + +
+
+ + + {#if editing} +
+ + +
+ + +
+
+ {:else} + +
+ {@const catInfo = GUIDE_CATEGORIES[guide.category]} +
+ {catInfo.label} + {DIFFICULTY_LABELS[guide.difficulty]} + {guide.estimatedMinutes} min +
+

{guide.title}

+

{guide.description}

+
+ {/if} + + + {#if run} +
+
+ + {#if isComplete} + Abgeschlossen + {:else} + {run.completedStepIds.length} / {steps.length} Schritte + {/if} + + {progress}% +
+
+
+
+ {#if isComplete} + + {/if} +
+ {:else if steps.length > 0} + + {/if} + + +
+ {#each sections as section (section.id)} + {@const sectionSteps = stepsForSection(section.id)} +
+

{section.title}

+ {#if section.content} +

{section.content}

+ {/if} + + {#if sectionSteps.length > 0} +
    + {#each sectionSteps as step (step.id)} + {@const done = isStepDone(step.id)} +
  • + +
  • + {/each} +
+ {/if} + + + {#if addingStepFor === section.id} +
+ e.key === 'Enter' && addStep(section.id)} + /> + + +
+ {:else} + + {/if} +
+ {/each} + + + {#if orphanSteps.length > 0} +
    + {#each orphanSteps as step (step.id)} + {@const done = isStepDone(step.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + +
+ {#if addingSectionFor === 'new'} +
+ e.key === 'Enter' && addSection()} + /> + + +
+ {:else} +
+ + +
+ {/if} + + {#if addingStepFor === '_orphan'} +
+ e.key === 'Enter' && addStep(null)} + /> + + +
+ {/if} +
+
+{/if} + +