mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
f17d748d85
commit
4f17626d3d
9 changed files with 1649 additions and 125 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
355
apps/mana/apps/web/src/lib/modules/guides/collections.ts
Normal file
355
apps/mana/apps/web/src/lib/modules/guides/collections.ts
Normal 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>>,
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
167
apps/mana/apps/web/src/lib/modules/guides/queries.ts
Normal file
167
apps/mana/apps/web/src/lib/modules/guides/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
161
apps/mana/apps/web/src/lib/modules/guides/types.ts
Normal file
161
apps/mana/apps/web/src/lib/modules/guides/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue