feat(guides): complete module with types, CRUD, detail view, and run tracking

Builds out the guides module from a static content display into a
full local-first module with interactive step-by-step progress:

- types.ts: LocalGuide/Section/Step/Run records, domain types, DTOs
- collections.ts: Dexie table accessors + guest seed (6 guides, 14
  sections, 22 steps with real instructional content)
- queries.ts: liveQuery hooks (useAllGuides, useGuide, useSections,
  useSteps, useLatestRun, useRunsByGuide) + type converters + search
- stores/guides.svelte.ts: full CRUD for guides/sections/steps,
  run tracking (startRun, completeStep, uncompleteStep, completeRun),
  cascade delete, all with encryptRecord
- views/DetailView.svelte: step-by-step viewer with sections as
  collapsible blocks, steps as interactive checklist, progress bar,
  inline editing, inline add for sections/steps
- ListView.svelte: DB-based instead of static, ViewProps, inline
  create, category filter, search, per-guide progress indicators
- apps.ts: detail view + paramKey registered
- crypto/registry.ts: guides/sections/steps encrypted fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 17:51:19 +02:00
parent f17d748d85
commit 4f17626d3d
9 changed files with 1649 additions and 125 deletions

View file

@ -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({

View file

@ -292,6 +292,8 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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<string, EncryptionConfig> = {
// 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'] },
};
/**

View file

@ -1,9 +1,37 @@
<!--
Guides — Workbench ListView
Static, curated list of interactive guides grouped by category.
Interactive guides list loaded from IndexedDB, with category filter,
search, inline create, run progress indicators, and detail navigation.
-->
<script lang="ts">
import { GUIDES, GUIDE_CATEGORIES, type GuideCategory } from './index';
import { BaseListView } from '@mana/shared-ui';
import type { ViewProps } from '$lib/app-registry';
import { useAllGuides, useRunsByGuide, searchGuides, getStepProgress } from './queries';
import { guidesStore } from './stores/guides.svelte';
import { GUIDE_CATEGORIES, DIFFICULTY_LABELS, type GuideCategory, type Guide } from './types';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalStep } from './types';
let { navigate }: ViewProps = $props();
const guidesQuery = useAllGuides();
const runsQuery = useRunsByGuide();
// Step counts per guide for progress display
const stepCountsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalStep>('steps').toArray();
const counts = new Map<string, number>();
for (const s of all) {
if (s.deletedAt) continue;
counts.set(s.guideId, (counts.get(s.guideId) ?? 0) + 1);
}
return counts;
}, new Map<string, number>());
const guides = $derived(guidesQuery.value);
const runs = $derived(runsQuery.value);
const stepCounts = $derived(stepCountsQuery.value);
let query = $state('');
let activeCategory = $state<GuideCategory | 'all'>('all');
@ -15,62 +43,141 @@
),
];
const filtered = $derived(
GUIDES.filter((g) => {
if (activeCategory !== 'all' && g.category !== activeCategory) return false;
if (!query.trim()) return true;
const q = query.toLowerCase();
return g.title.toLowerCase().includes(q) || g.description.toLowerCase().includes(q);
})
);
const filtered = $derived(() => {
let result = searchGuides(guides, query);
if (activeCategory !== 'all') {
result = result.filter((g) => g.category === activeCategory);
}
return result;
});
const difficultyLabel: Record<string, string> = {
beginner: 'Einsteiger',
intermediate: 'Fortgeschritten',
advanced: 'Profi',
};
// ── Inline create ──────────────────────────────────────
let creating = $state(false);
let newTitle = $state('');
let newCategory = $state<GuideCategory>('getting-started');
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
const title = newTitle.trim();
if (!title) return;
const guide = await guidesStore.createGuide({ title, category: newCategory });
newTitle = '';
creating = false;
navigate('detail', {
guideId: guide.id,
_siblingIds: [...guides.map((g) => g.id), guide.id],
_siblingKey: 'guideId',
});
}
function openGuide(guide: Guide) {
navigate('detail', {
guideId: guide.id,
_siblingIds: filtered().map((g) => g.id),
_siblingKey: 'guideId',
});
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<input
bind:value={query}
placeholder="Guides durchsuchen..."
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<BaseListView items={filtered()} getKey={(g) => g.id} emptyTitle="Keine Guides gefunden">
{#snippet toolbar()}
<!-- Search -->
<input
bind:value={query}
placeholder="Guides durchsuchen..."
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<div class="flex flex-wrap gap-1">
{#each categories as cat (cat.id)}
<button
onclick={() => (activeCategory = cat.id)}
class="rounded-full px-3 py-1 text-xs transition-colors
{activeCategory === cat.id
? 'bg-white/15 text-white'
: 'bg-white/5 text-white/50 hover:bg-white/10'}"
>
{cat.label}
</button>
{/each}
</div>
<div class="flex-1 space-y-2 overflow-auto">
{#each filtered as guide (guide.id)}
{@const meta = GUIDE_CATEGORIES[guide.category]}
<div
class="rounded-md border border-white/5 bg-white/5 p-3 transition-colors hover:bg-white/10"
>
<div class="mb-1 flex items-center gap-2">
<span class="h-2 w-2 rounded-full {meta.color}"></span>
<span class="text-xs text-white/40">{meta.label}</span>
<span class="ml-auto text-xs text-white/30">{guide.estimatedMinutes} min</span>
</div>
<h3 class="text-sm font-medium text-white/90">{guide.title}</h3>
<p class="mt-0.5 text-xs text-white/50">{guide.description}</p>
<p class="mt-1 text-[10px] uppercase tracking-wide text-white/30">
{difficultyLabel[guide.difficulty]}
</p>
<!-- Category filter + create toggle -->
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap gap-1">
{#each categories as cat (cat.id)}
<button
type="button"
onclick={() => (activeCategory = cat.id)}
class="rounded-full px-3 py-1 text-xs transition-colors
{activeCategory === cat.id
? 'bg-white/15 text-white'
: 'bg-white/5 text-white/50 hover:bg-white/10'}"
>
{cat.label}
</button>
{/each}
</div>
{:else}
<p class="py-8 text-center text-xs text-white/40">Keine Guides gefunden.</p>
{/each}
</div>
</div>
<button
type="button"
class="shrink-0 text-xs text-white/50 transition-colors hover:text-white/80"
onclick={() => (creating = !creating)}
>
{creating ? 'Abbrechen' : '+ Neuer Guide'}
</button>
</div>
{#if creating}
<form class="flex flex-col gap-2 rounded-lg bg-white/5 p-3" onsubmit={handleCreate}>
<input
type="text"
bind:value={newTitle}
placeholder="Guide-Titel"
required
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<select
bind:value={newCategory}
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white focus:border-white/20 focus:outline-none"
>
{#each Object.entries(GUIDE_CATEGORIES) as [key, info] (key)}
<option value={key}>{info.label}</option>
{/each}
</select>
<button
type="submit"
class="rounded-md bg-teal-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!newTitle.trim()}
>
Guide erstellen
</button>
</form>
{/if}
{/snippet}
{#snippet header()}
<span>{guides.length} Guides</span>
{/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)}
<button
onclick={() => openGuide(guide)}
class="mb-2 w-full rounded-md border border-white/5 bg-white/5 p-3 text-left transition-colors hover:bg-white/10"
>
<div class="mb-1 flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {meta.color}"></span>
<span class="text-xs text-white/40">{meta.label}</span>
<span class="ml-auto text-xs text-white/30">{guide.estimatedMinutes} min</span>
</div>
<h3 class="text-sm font-medium text-white/90">{guide.title}</h3>
<p class="mt-0.5 text-xs text-white/50">{guide.description}</p>
<div class="mt-1.5 flex items-center gap-2">
<span class="text-[10px] uppercase tracking-wide text-white/30">
{DIFFICULTY_LABELS[guide.difficulty]}
</span>
{#if run}
{#if run.completedAt}
<span class="ml-auto text-[10px] font-medium text-green-400">Abgeschlossen</span>
{:else if totalSteps > 0}
<div class="ml-auto flex items-center gap-1.5">
<div class="h-1 w-12 rounded-full bg-white/10">
<div class="h-full rounded-full bg-teal-400" style="width: {progress}%"></div>
</div>
<span class="text-[10px] text-white/30">{progress}%</span>
</div>
{/if}
{/if}
</div>
</button>
{/snippet}
</BaseListView>

View file

@ -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<LocalGuide>('guides');
export const sectionTable = db.table<LocalSection>('sections');
export const stepTable = db.table<LocalStep>('steps');
export const guideCollectionTable = db.table<LocalGuideCollection>('guideCollections');
export const runTable = db.table<LocalRun>('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<Record<string, unknown>>,
};

View file

@ -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<GuideCategory, { label: string; color: string }> = {
'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';

View file

@ -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<LocalGuide>('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<LocalGuide>('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<LocalSection>('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<LocalStep>('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<LocalRun>('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<LocalRun>('runs').toArray();
const visible = all.filter((r) => !r.deletedAt);
const map = new Map<string, Run>();
// 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<string, Run>());
}
// ─── 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);
}

View file

@ -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<Guide> {
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<void> {
const updates: Record<string, unknown> = { 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<void> {
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<Section> {
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<void> {
const updates: Record<string, unknown> = { 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<void> {
const now = new Date().toISOString();
await sectionTable.update(id, { deletedAt: now, updatedAt: now });
},
// ─── Steps ───────────────────────────────────────────
async createStep(dto: CreateStepDto): Promise<Step> {
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<void> {
const updates: Record<string, unknown> = { 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<void> {
const now = new Date().toISOString();
await stepTable.update(id, { deletedAt: now, updatedAt: now });
},
// ─── Runs (Progress Tracking) ────────────────────────
async startRun(guideId: string): Promise<Run> {
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<void> {
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<void> {
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<void> {
await runTable.update(runId, {
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteRun(id: string): Promise<void> {
const now = new Date().toISOString();
await runTable.update(id, { deletedAt: now, updatedAt: now });
},
};

View file

@ -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<GuideCategory, { label: string; color: string }> = {
'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<GuideDifficulty, string> = {
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;
}

View file

@ -0,0 +1,582 @@
<!--
Guide DetailView — Step-by-step guide viewer with run progress tracking.
Shows guide metadata, sections as collapsible groups, and steps as a
checklist. Starting a guide creates a Run; completing all steps marks
the run as done.
-->
<script lang="ts">
import type { ViewProps } from '$lib/app-registry';
import { useGuide, useSections, useSteps, useLatestRun, getStepProgress } from '../queries';
import { guidesStore } from '../stores/guides.svelte';
import { GUIDE_CATEGORIES, DIFFICULTY_LABELS } from '../types';
import type { Step, Section } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let guideId = $derived((params.guideId as string) ?? '');
const guideQuery = useGuide(() => guideId);
const sectionsQuery = useSections(() => guideId);
const stepsQuery = useSteps(() => guideId);
const runQuery = useLatestRun(() => guideId);
const guide = $derived(guideQuery.value);
const sections = $derived(sectionsQuery.value);
const steps = $derived(stepsQuery.value);
const run = $derived(runQuery.value);
const progress = $derived(getStepProgress(run, steps.length));
const isComplete = $derived(run?.completedAt != null);
const isActive = $derived(run != null && !run.completedAt);
function stepsForSection(sectionId: string): Step[] {
return steps.filter((s) => s.sectionId === sectionId);
}
const orphanSteps = $derived(steps.filter((s) => !s.sectionId));
function isStepDone(stepId: string): boolean {
return run?.completedStepIds.includes(stepId) ?? false;
}
async function startGuide() {
await guidesStore.startRun(guideId);
}
async function toggleStep(stepId: string) {
if (!run) return;
if (isStepDone(stepId)) {
await guidesStore.uncompleteStep(run.id, stepId);
} else {
await guidesStore.completeStep(run.id, stepId);
// Auto-complete run when all steps done
const newCompleted = [...run.completedStepIds, stepId];
if (newCompleted.length >= steps.length && steps.length > 0) {
await guidesStore.completeRun(run.id);
}
}
}
async function resetProgress() {
if (!run) return;
await guidesStore.deleteRun(run.id);
}
// ── Editing state ───────────────────────────────────
let editing = $state(false);
let titleDraft = $state('');
let descDraft = $state('');
function startEdit() {
if (!guide) return;
titleDraft = guide.title;
descDraft = guide.description;
editing = true;
}
async function saveEdit() {
if (!guide) return;
await guidesStore.updateGuide(guide.id, {
title: titleDraft,
description: descDraft,
});
editing = false;
}
async function handleDelete() {
if (!guide) return;
if (!confirm(`Guide "${guide.title}" wirklich löschen?`)) return;
await guidesStore.deleteGuide(guide.id);
goBack();
}
// ── Add section / step inline ───────────────────────
let addingSectionFor = $state<string | null>(null); // null = top-level
let newSectionTitle = $state('');
let addingStepFor = $state<string | null>(null);
let newStepTitle = $state('');
async function addSection() {
if (!newSectionTitle.trim()) return;
await guidesStore.createSection({ guideId, title: newSectionTitle.trim() });
newSectionTitle = '';
addingSectionFor = null;
}
async function addStep(sectionId: string | null) {
if (!newStepTitle.trim()) return;
await guidesStore.createStep({
guideId,
sectionId: sectionId ?? undefined,
title: newStepTitle.trim(),
});
newStepTitle = '';
addingStepFor = null;
}
</script>
{#if !guide}
<div class="loading">Lade Guide...</div>
{:else}
<div class="detail">
<!-- Header -->
<header class="detail-header">
<div class="header-actions">
<button class="action-btn" onclick={startEdit}>Bearbeiten</button>
<button class="action-btn danger" onclick={handleDelete}>Löschen</button>
</div>
</header>
<!-- Editing form -->
{#if editing}
<div class="edit-form">
<input class="title-input" bind:value={titleDraft} placeholder="Guide-Titel" />
<textarea class="desc-input" bind:value={descDraft} rows="3" placeholder="Beschreibung"
></textarea>
<div class="form-actions">
<button class="action-btn" onclick={() => (editing = false)}>Abbrechen</button>
<button class="action-btn primary" onclick={saveEdit}>Speichern</button>
</div>
</div>
{:else}
<!-- Guide metadata -->
<div class="meta">
{@const catInfo = GUIDE_CATEGORIES[guide.category]}
<div class="meta-badges">
<span class="badge {catInfo.color}">{catInfo.label}</span>
<span class="badge bg-white/10">{DIFFICULTY_LABELS[guide.difficulty]}</span>
<span class="badge bg-white/10">{guide.estimatedMinutes} min</span>
</div>
<h1 class="title">{guide.title}</h1>
<p class="description">{guide.description}</p>
</div>
{/if}
<!-- Progress bar -->
{#if run}
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">
{#if isComplete}
Abgeschlossen
{:else}
{run.completedStepIds.length} / {steps.length} Schritte
{/if}
</span>
<span class="progress-pct">{progress}%</span>
</div>
<div class="progress-bar">
<div
class="progress-fill {isComplete ? 'complete' : ''}"
style="width: {progress}%"
></div>
</div>
{#if isComplete}
<button class="action-btn small" onclick={resetProgress}>Fortschritt zurücksetzen</button>
{/if}
</div>
{:else if steps.length > 0}
<button class="action-btn primary" onclick={startGuide}>Guide starten</button>
{/if}
<!-- Sections + Steps -->
<div class="sections">
{#each sections as section (section.id)}
{@const sectionSteps = stepsForSection(section.id)}
<div class="section-block">
<h2 class="section-title">{section.title}</h2>
{#if section.content}
<p class="section-content">{section.content}</p>
{/if}
{#if sectionSteps.length > 0}
<ul class="step-list">
{#each sectionSteps as step (step.id)}
{@const done = isStepDone(step.id)}
<li class="step-item">
<button
class="step-btn"
class:done
disabled={!isActive}
onclick={() => toggleStep(step.id)}
>
<span class="check">{done ? '✓' : ''}</span>
<div class="step-body">
<span class="step-title" class:done>{step.title}</span>
{#if step.content}
<span class="step-content">{step.content}</span>
{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
<!-- Add step to section -->
{#if addingStepFor === section.id}
<div class="inline-add">
<input
type="text"
bind:value={newStepTitle}
placeholder="Neuer Schritt..."
class="inline-input"
onkeydown={(e) => e.key === 'Enter' && addStep(section.id)}
/>
<button class="action-btn primary small" onclick={() => addStep(section.id)}>+</button
>
<button class="action-btn small" onclick={() => (addingStepFor = null)}>×</button>
</div>
{:else}
<button
class="add-link"
onclick={() => {
addingStepFor = section.id;
newStepTitle = '';
}}
>
+ Schritt
</button>
{/if}
</div>
{/each}
<!-- Orphan steps (no section) -->
{#if orphanSteps.length > 0}
<ul class="step-list">
{#each orphanSteps as step (step.id)}
{@const done = isStepDone(step.id)}
<li class="step-item">
<button
class="step-btn"
class:done
disabled={!isActive}
onclick={() => toggleStep(step.id)}
>
<span class="check">{done ? '✓' : ''}</span>
<div class="step-body">
<span class="step-title" class:done>{step.title}</span>
{#if step.content}
<span class="step-content">{step.content}</span>
{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Add section / step -->
<div class="add-area">
{#if addingSectionFor === 'new'}
<div class="inline-add">
<input
type="text"
bind:value={newSectionTitle}
placeholder="Neuer Abschnitt..."
class="inline-input"
onkeydown={(e) => e.key === 'Enter' && addSection()}
/>
<button class="action-btn primary small" onclick={addSection}>+</button>
<button class="action-btn small" onclick={() => (addingSectionFor = null)}>×</button>
</div>
{:else}
<div class="add-buttons">
<button
class="add-link"
onclick={() => {
addingSectionFor = 'new';
newSectionTitle = '';
}}
>
+ Abschnitt
</button>
<button
class="add-link"
onclick={() => {
addingStepFor = '_orphan';
newStepTitle = '';
}}
>
+ Schritt
</button>
</div>
{/if}
{#if addingStepFor === '_orphan'}
<div class="inline-add">
<input
type="text"
bind:value={newStepTitle}
placeholder="Neuer Schritt..."
class="inline-input"
onkeydown={(e) => e.key === 'Enter' && addStep(null)}
/>
<button class="action-btn primary small" onclick={() => addStep(null)}>+</button>
<button class="action-btn small" onclick={() => (addingStepFor = null)}>×</button>
</div>
{/if}
</div>
</div>
{/if}
<style>
.detail {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
max-width: 640px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: flex-end;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.action-btn:hover {
background: hsl(var(--color-muted));
}
.action-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.action-btn.danger {
color: rgb(220, 38, 38);
}
.action-btn.small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.meta-badges {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: white;
}
.title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.description {
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
}
.edit-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.title-input,
.desc-input {
padding: 0.625rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.title-input {
font-size: 1.25rem;
font-weight: 600;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-header {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.progress-pct {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.progress-bar {
height: 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 9999px;
background: hsl(var(--color-primary));
transition: width 0.3s ease;
}
.progress-fill.complete {
background: rgb(34, 197, 94);
}
.sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.625rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
}
.section-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.section-content {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.step-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.step-btn {
display: flex;
align-items: flex-start;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: transparent;
text-align: left;
cursor: pointer;
color: hsl(var(--color-foreground));
}
.step-btn:not(:disabled):hover {
background: hsl(var(--color-muted) / 0.5);
}
.step-btn:disabled {
cursor: default;
opacity: 0.6;
}
.check {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
border: 1.5px solid hsl(var(--color-border));
font-size: 0.75rem;
flex-shrink: 0;
margin-top: 0.0625rem;
}
.step-btn.done .check {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: white;
}
.step-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.step-title {
font-size: 0.875rem;
font-weight: 500;
}
.step-title.done {
text-decoration: line-through;
opacity: 0.5;
}
.step-content {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.add-link {
background: none;
border: none;
color: hsl(var(--color-primary));
font-size: 0.75rem;
cursor: pointer;
padding: 0.25rem 0;
}
.add-link:hover {
text-decoration: underline;
}
.inline-add {
display: flex;
gap: 0.375rem;
align-items: center;
}
.inline-input {
flex: 1;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.add-area {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.add-buttons {
display: flex;
gap: 1rem;
}
.loading {
padding: 2rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
</style>