feat(guides): add mana guides app — step-by-step playbook app

New app at apps/guides/ with local-first architecture (Dexie.js + mana-sync).

- 5 collections: guides, sections, steps, collections, runs
- Library view: card grid with search, category & difficulty filters
- Guide detail: sections/steps overview, start-run buttons
- Run mode: scroll (all steps) + focus (one step at a time) with note support
- Collections view: learning paths with progress bars
- History view: all runs with timestamps and duration
- Guest seed: 3 demo guides (dev setup, pasta recipe, git basics)
- GuideCard with run-status indicator (○◑●⟳)
- GuideEditModal with emoji, color, difficulty, tags
- Registered in shared-branding: port 5200, teal #0d9488, guides.mana.how
- Plan doc: .claude/plans/mana-guides.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 20:12:36 +02:00
parent 5e4518b418
commit 0a9c38161b
27 changed files with 2460 additions and 0 deletions

View file

@ -0,0 +1,233 @@
# mana guides — Implementation Plan
## Concept
Guides ist eine **Playbook-App**: jeder Guide ist gleichzeitig eine schöne Karte zum Entdecken
(Bibliothek), ein ausführbarer Prozess mit Ausführungshistorie (Executer) und ein Baustein in
strukturierten Lernpfaden (Teacher). Drei Konzepte, ein konsistentes Datenmodell.
**Positionierung:** Zwischen context (freie Dokumente) und todo (Tasks). Guides sind
strukturierte, ausführbare Schritt-für-Schritt-Anleitungen.
---
## Architektur-Entscheidungen
| Thema | Entscheidung | Begründung |
|-------|-------------|------------|
| Stack | SvelteKit 5 + Svelte Runes + Tailwind | Ecosystem-Standard |
| Daten | Local-first (Dexie.js + mana-sync) | Kein Server für MVP, offline-fähig |
| Server | Kein MVP-Server | Phase 2: Web-Import via mana-search, Sharing |
| Auth | mana-core-auth, allowGuest=true | Guides funktionieren als Gast |
| Tier | beta | Entwicklungsphase |
| Port | 5200 | Nächster freier Port nach calc (5198) |
| Farbe | #0d9488 (teal) | Einzigartig im Ecosystem |
| Subdomain | guides.mana.how | |
---
## Datenmodell
Alle Collections local-first via `createLocalStore()`, 5 Collections:
```typescript
LocalGuide {
id, title, description?, coverEmoji?, coverColor?,
category, difficulty: 'easy'|'medium'|'hard',
estimatedMinutes?, tags: string[],
collectionId?, // Zugehörigkeit zu einer Collection
orderInCollection?,
xpReward?, // skilltree bridge (optional)
skillId?,
createdAt, updatedAt, deletedAt // BaseRecord
}
LocalSection {
id, guideId, title, order
}
LocalStep {
id, guideId, sectionId?,
order, title, content?, // markdown
type: 'instruction'|'warning'|'tip'|'checkpoint'|'code',
checkable: boolean
}
LocalCollection {
id, title, description?, coverEmoji?, coverColor?,
type: 'path'|'library', // path=geordnet, library=ungeordnet
guideOrder: string[] // geordnete Guide-IDs für paths
}
LocalRun {
id, guideId,
startedAt, completedAt?,
mode: 'scroll'|'focus',
stepStates: Record<stepId, { done: boolean; doneAt?: string; notes?: string }>
}
```
---
## Route-Struktur
```
apps/guides/apps/web/src/routes/
+layout.svelte root: auth init, theme, i18n
(app)/
+layout.svelte auth gate, nav, FAB
+page.svelte Bibliothek: Grid aller Guides
guide/
[id]/
+page.svelte Guide-Detail: Sections, Steps, Run starten
run/
+page.svelte Run-Modus: Scroll oder Fokus
collections/
+page.svelte Collections-Liste
[id]/
+page.svelte Collection-Detail / Pfad-View
history/
+page.svelte Alle Runs, Ausführungshistorie
(auth)/
login/+page.svelte
register/+page.svelte
forgot-password/+page.svelte
reset-password/+page.svelte
```
---
## UI-Konzept
### Bibliothek (Hauptansicht)
- Masonry- oder gleichmäßiges Grid mit Guide-Karten
- Karte zeigt: Emoji/Color, Titel, Kategorie, Schwierigkeit (⭐), Zeit, Run-Status-Ring
- Filter: Kategorie / Schwierigkeit / Status (Neu/Aktiv/Abgeschlossen)
- FAB: "Guide erstellen"
### Guide-Karte Status-Indikator
```
○ Neu (nie ausgeführt)
◑ Aktiv (laufender Run)
● Abgeschlossen (letzter Run fertig)
⟳ Wiederholt (3+ Runs = SOP-Indikator)
```
### Run-Modi
**Scroll-Modus** (Tutorials, Anleitungen):
- Alle Steps sichtbar, fortlaufend scrollbar
- Erledigte Steps werden durchgestrichen/grün
- Fortschrittsbalken oben
**Fokus-Modus** (SOPs, Routinen):
- Ein Step fullscreen
- Großer "Abschließen"-Button
- Notiz hinzufügen möglich
- Navigation: Zurück / Weiter
### Collections / Pfad-View
- `path`-Collections: sequentielle Ansicht mit Fortschrittsbalken, XP-Belohnung
- `library`-Collections: Themed Kochbuch-Stil, ungeordneter Grid
---
## Phasen
### Phase 1 — MVP (jetzt implementiert) ✓
- [x] Monorepo-Skelett (package.json, config-files)
- [x] Local-Store (5 Collections)
- [x] Guest-Seed (3 Demo-Guides)
- [x] Root-Layout + Auth-Layout
- [x] Bibliothek-View (+page.svelte)
- [x] Guide-Detail-View
- [x] Run-Modus (Scroll + Fokus)
- [x] Collections-View
- [x] Verlauf-View
- [x] GuideCard-Komponente
- [x] GuideEditModal
- [x] RunView-Komponente
- [x] Registrierung in mana-apps.ts + app-icons.ts
### Phase 2 — Web-Import & Sharing
- [ ] Hono/Bun-Server (apps/guides/apps/server/)
- [ ] Web-Import: URL → Guide via mana-search
- [ ] Guide-Export: JSON / Markdown
- [ ] Guide-Sharing (öffentliche Guides)
- [ ] QR-Code für Guide-Sharing
### Phase 3 — KI & Integration
- [ ] KI-Guide-Generator: Text/Paste → strukturierter Guide via mana-llm
- [ ] skilltree-XP-Bridge (completedRun → XP-Event)
- [ ] calendar-Integration: Guide als Kalender-Event einplanen
- [ ] todo-Integration: Guide-Schritt als Task erstellen
### Phase 4 — Community
- [ ] Öffentliche Guide-Bibliothek (community guides)
- [ ] Guide-Bewertungen und Kommentare
- [ ] Guide-Templates (Starter-Vorlagen)
---
## Dateien
```
apps/guides/
├── package.json
└── apps/
└── web/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── app.html
│ ├── app.css
│ ├── app.d.ts
│ ├── hooks.client.ts
│ ├── lib/
│ │ ├── data/
│ │ │ ├── local-store.ts
│ │ │ └── guest-seed.ts
│ │ ├── stores/
│ │ │ ├── auth.svelte.ts
│ │ │ ├── guides.svelte.ts
│ │ │ └── runs.svelte.ts
│ │ └── components/
│ │ ├── GuideCard.svelte
│ │ ├── GuideEditModal.svelte
│ │ ├── StepEditor.svelte
│ │ └── RunView.svelte
│ └── routes/
│ ├── +layout.svelte
│ ├── (app)/
│ │ ├── +layout.svelte
│ │ ├── +page.svelte
│ │ ├── guide/[id]/
│ │ │ ├── +page.svelte
│ │ │ └── run/+page.svelte
│ │ ├── collections/
│ │ │ ├── +page.svelte
│ │ │ └── [id]/+page.svelte
│ │ └── history/+page.svelte
│ └── (auth)/
│ ├── login/+page.svelte
│ ├── register/+page.svelte
│ ├── forgot-password/+page.svelte
│ └── reset-password/+page.svelte
Registrierung:
packages/shared-branding/src/app-icons.ts → guidesSvg + APP_ICONS.guides
packages/shared-branding/src/mana-apps.ts → MANA_APPS entry + APP_URLS entry
```
---
## Abgrenzung zu bestehenden Apps
| App | Überlappung | Abgrenzung |
|-----|-------------|------------|
| context | Dokumente, Wissen | context = freie Dokumente, guides = strukturierte Ausführung |
| todo | Aufgaben, Checklisten | todo = Tasks, guides = Prozesse mit History |
| questions | Recherche | questions = Q&A, guides = How-To |
| manadeck | Lernen | manadeck = Karteikarten/Spaced-Repetition, guides = Schritt-für-Schritt |
| skilltree | Skill-Progression | skilltree = XP-Tracking, guides = Quelle von XP (optional) |

View file

@ -0,0 +1,61 @@
{
"name": "@guides/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"jsdom": "^25.0.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vitest": "^4.1.0"
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-stores": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/feedback": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/help": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*"
},
"type": "module"
}

View file

@ -0,0 +1,34 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
:root {
/* Guides App - Teal Theme */
--color-primary: #0d9488;
--color-primary-hover: #0f766e;
--color-primary-light: #14b8a6;
--color-primary-dark: #115e59;
--color-secondary: #f0fdfa;
--color-secondary-hover: #ccfbf1;
--color-accent: #2dd4bf;
--color-accent-hover: #14b8a6;
/* Difficulty colors */
--color-difficulty-easy: #22c55e;
--color-difficulty-medium: #f59e0b;
--color-difficulty-hard: #ef4444;
/* Step type colors */
--color-step-instruction: #0d9488;
--color-step-warning: #f97316;
--color-step-tip: #8b5cf6;
--color-step-checkpoint: #3b82f6;
--color-step-code: #1e293b;
}

2
apps/guides/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const __BUILD_HASH__: string;
declare const __BUILD_TIME__: string;

View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0d9488" />
<meta name="application-name" content="Guides" />
<meta name="description" content="Schritt-für-Schritt Anleitungen & Playbooks" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Guides" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/icon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon.png" />
<title>Guides</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
import type { HandleClientError } from '@sveltejs/kit';
initErrorTracking({
serviceName: 'guides-web',
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
environment: import.meta.env.MODE,
});
export const handleError: HandleClientError = ({ error }) => {
handleSvelteError(error);
};

View file

@ -0,0 +1,79 @@
<script lang="ts">
import type { LocalGuide } from '$lib/data/local-store.js';
interface Props {
guide: LocalGuide;
runCount: number;
hasActiveRun: boolean;
}
let { guide, runCount, hasActiveRun }: Props = $props();
const difficultyConfig = {
easy: { label: 'Einfach', dot: 'bg-green-500' },
medium: { label: 'Mittel', dot: 'bg-amber-400' },
hard: { label: 'Schwer', dot: 'bg-red-400' },
};
// Run status indicator
// ○ Neu ◑ Aktiv ⟳ Wiederholt (3+) ● Abgeschlossen
let runStatus = $derived(
hasActiveRun ? 'active' : runCount >= 3 ? 'repeated' : runCount > 0 ? 'done' : 'new'
);
const statusConfig = {
new: { icon: '○', label: 'Neu', color: 'text-muted-foreground' },
active: { icon: '◑', label: 'Aktiv', color: 'text-primary' },
done: { icon: '●', label: 'Abgeschlossen', color: 'text-green-500' },
repeated: { icon: '⟳', label: `${runCount}× abgeschlossen`, color: 'text-teal-500' },
};
</script>
<a
href="/guide/{guide.id}"
class="group flex flex-col overflow-hidden rounded-2xl border border-border bg-surface transition-all hover:border-primary/40 hover:shadow-md hover:-translate-y-0.5"
>
<!-- Cover -->
<div
class="flex h-28 items-center justify-center"
style="background-color: {guide.coverColor ?? '#0d9488'}18"
>
<span class="text-5xl transition-transform group-hover:scale-110">
{guide.coverEmoji ?? '📖'}
</span>
</div>
<!-- Content -->
<div class="flex flex-1 flex-col p-4">
<div class="mb-1 flex items-start justify-between gap-2">
<h3 class="flex-1 text-sm font-semibold leading-snug text-foreground line-clamp-2 group-hover:text-primary">
{guide.title}
</h3>
</div>
{#if guide.description}
<p class="mb-3 text-xs text-muted-foreground line-clamp-2">{guide.description}</p>
{/if}
<div class="mt-auto flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Difficulty dot -->
<div class="flex items-center gap-1">
<span class="h-1.5 w-1.5 rounded-full {difficultyConfig[guide.difficulty].dot}"></span>
<span class="text-xs text-muted-foreground">{difficultyConfig[guide.difficulty].label}</span>
</div>
{#if guide.estimatedMinutes}
<span class="text-xs text-muted-foreground">· {guide.estimatedMinutes}min</span>
{/if}
</div>
<!-- Run status -->
<span class="text-xs {statusConfig[runStatus].color}" title={statusConfig[runStatus].label}>
{statusConfig[runStatus].icon}
{#if runStatus === 'repeated'}
<span class="ml-0.5">{runCount}×</span>
{/if}
</span>
</div>
</div>
</a>

View file

@ -0,0 +1,251 @@
<script lang="ts">
import type { LocalGuide, Difficulty } from '$lib/data/local-store.js';
type GuideInput = Omit<LocalGuide, keyof import('@manacore/local-store').BaseRecord>;
interface Props {
open: boolean;
guide?: LocalGuide;
onClose: () => void;
onSave: (data: GuideInput) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
}
let { open, guide, onClose, onSave, onDelete }: Props = $props();
// Form state
let title = $state(guide?.title ?? '');
let description = $state(guide?.description ?? '');
let coverEmoji = $state(guide?.coverEmoji ?? '📖');
let category = $state(guide?.category ?? 'Allgemein');
let difficulty = $state<Difficulty>(guide?.difficulty ?? 'easy');
let estimatedMinutes = $state(guide?.estimatedMinutes ?? 0);
let tagsInput = $state((guide?.tags ?? []).join(', '));
let saving = $state(false);
// Re-init when guide prop changes
$effect(() => {
if (guide) {
title = guide.title;
description = guide.description ?? '';
coverEmoji = guide.coverEmoji ?? '📖';
category = guide.category;
difficulty = guide.difficulty;
estimatedMinutes = guide.estimatedMinutes ?? 0;
tagsInput = guide.tags.join(', ');
}
});
const COVER_COLORS = [
'#0d9488', '#3b82f6', '#8b5cf6', '#f59e0b',
'#ef4444', '#ec4899', '#10b981', '#f97316',
];
let coverColor = $state(guide?.coverColor ?? COVER_COLORS[0]);
const DIFFICULTY_OPTIONS: { value: Difficulty; label: string; color: string }[] = [
{ value: 'easy', label: '⭐ Einfach', color: 'border-green-400 bg-green-50 text-green-700' },
{ value: 'medium', label: '⭐⭐ Mittel', color: 'border-amber-400 bg-amber-50 text-amber-700' },
{ value: 'hard', label: '⭐⭐⭐ Schwer', color: 'border-red-400 bg-red-50 text-red-700' },
];
const CATEGORY_SUGGESTIONS = ['Technik', 'Kochen', 'Sport', 'Lernen', 'Arbeit', 'Haushalt', 'Hobby', 'Allgemein'];
async function handleSave() {
if (!title.trim()) return;
saving = true;
try {
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean);
await onSave({
title: title.trim(),
description: description.trim() || undefined,
coverEmoji,
coverColor,
category,
difficulty,
estimatedMinutes: estimatedMinutes > 0 ? estimatedMinutes : undefined,
tags,
collectionId: guide?.collectionId,
orderInCollection: guide?.orderInCollection,
xpReward: guide?.xpReward,
skillId: guide?.skillId,
});
} finally {
saving = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') handleSave();
}
function handleBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4"
onmousedown={handleBackdrop}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
role="dialog"
aria-modal="true"
aria-label={guide ? 'Anleitung bearbeiten' : 'Neue Anleitung'}
class="w-full max-w-lg overflow-hidden rounded-t-2xl bg-background shadow-xl sm:rounded-2xl"
onkeydown={handleKeydown}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-5 py-4">
<h2 class="font-semibold text-foreground">
{guide ? 'Anleitung bearbeiten' : 'Neue Anleitung'}
</h2>
<button onclick={onClose} class="text-muted-foreground hover:text-foreground"></button>
</div>
<!-- Body -->
<div class="max-h-[70vh] overflow-y-auto p-5 space-y-4">
<!-- Emoji + Color picker row -->
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
<p class="mb-1 text-xs text-muted-foreground">Emoji</p>
<input
type="text"
bind:value={coverEmoji}
maxlength="2"
class="h-12 w-16 rounded-xl border border-border bg-surface text-center text-2xl focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div class="flex-1">
<p class="mb-1 text-xs text-muted-foreground">Farbe</p>
<div class="flex gap-2 flex-wrap">
{#each COVER_COLORS as color}
<button
onclick={() => (coverColor = color)}
class="h-7 w-7 rounded-full transition-transform hover:scale-110 {coverColor === color ? 'ring-2 ring-offset-2 ring-foreground' : ''}"
style="background-color: {color}"
aria-label={color}
></button>
{/each}
</div>
</div>
</div>
<!-- Title -->
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Titel *</label>
<input
type="text"
bind:value={title}
placeholder="z.B. Server deployen, Pasta Rezept..."
class="w-full rounded-xl border border-border bg-surface px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
autofocus
/>
</div>
<!-- Description -->
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Beschreibung</label>
<textarea
bind:value={description}
placeholder="Kurze Beschreibung..."
rows="2"
class="w-full resize-none rounded-xl border border-border bg-surface px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
></textarea>
</div>
<!-- Category + Difficulty -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Kategorie</label>
<input
type="text"
bind:value={category}
list="category-suggestions"
class="w-full rounded-xl border border-border bg-surface px-3 py-2.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<datalist id="category-suggestions">
{#each CATEGORY_SUGGESTIONS as cat}
<option value={cat}></option>
{/each}
</datalist>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Zeitaufwand (min)</label>
<input
type="number"
bind:value={estimatedMinutes}
min="0"
step="5"
class="w-full rounded-xl border border-border bg-surface px-3 py-2.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
<!-- Difficulty -->
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Schwierigkeit</label>
<div class="flex gap-2">
{#each DIFFICULTY_OPTIONS as opt}
<button
onclick={() => (difficulty = opt.value)}
class="flex-1 rounded-lg border py-2 text-xs font-medium transition-colors
{difficulty === opt.value ? opt.color : 'border-border text-muted-foreground hover:bg-accent'}"
>
{opt.label}
</button>
{/each}
</div>
</div>
<!-- Tags -->
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">
Tags <span class="font-normal">(kommagetrennt)</span>
</label>
<input
type="text"
bind:value={tagsInput}
placeholder="setup, mac, developer"
class="w-full rounded-xl border border-border bg-surface px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-border px-5 py-4">
<div>
{#if guide && onDelete}
<button
onclick={() => onDelete(guide!.id)}
class="text-sm text-red-500 hover:text-red-600"
>
Löschen
</button>
{/if}
</div>
<div class="flex gap-3">
<button
onclick={onClose}
class="rounded-xl border border-border px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={!title.trim() || saving}
class="rounded-xl bg-primary px-5 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-50"
>
{saving ? 'Speichern...' : guide ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,245 @@
/**
* Guest seed data for the Guides app.
*
* Three demo guides that showcase the app's capabilities:
* 1. "Entwicklungsumgebung einrichten" tech SOP (medium, 3 sections)
* 2. "Perfekte Pasta kochen" recipe (easy, flat steps)
* 3. "Git-Grundlagen" learning path guide (hard, sections)
*
* One demo collection: "Dev Setup Pfad" (path type)
*/
import type { LocalGuide, LocalSection, LocalStep, LocalCollection } from './local-store';
// ─── Guides ─────────────────────────────────────────────────
export const guestGuides: LocalGuide[] = [
{
id: 'guide-dev-setup',
title: 'Entwicklungsumgebung einrichten',
description: 'Node.js, Git und VS Code auf einem neuen Mac konfigurieren.',
coverEmoji: '💻',
coverColor: '#0d9488',
category: 'Technik',
difficulty: 'medium',
estimatedMinutes: 45,
tags: ['setup', 'mac', 'developer'],
collectionId: 'col-dev',
orderInCollection: 0,
},
{
id: 'guide-pasta',
title: 'Perfekte Pasta alla Norma',
description: 'Klassisches sizilianisches Gericht mit Auberginen und Ricotta.',
coverEmoji: '🍝',
coverColor: '#f97316',
category: 'Kochen',
difficulty: 'easy',
estimatedMinutes: 30,
tags: ['italienisch', 'vegetarisch', 'pasta'],
},
{
id: 'guide-git',
title: 'Git-Grundlagen verstehen',
description: 'Commits, Branches, Merges und Pull Requests — von Null auf solide Basis.',
coverEmoji: '🌿',
coverColor: '#8b5cf6',
category: 'Technik',
difficulty: 'hard',
estimatedMinutes: 90,
tags: ['git', 'versionskontrolle', 'developer'],
collectionId: 'col-dev',
orderInCollection: 1,
},
];
// ─── Sections ───────────────────────────────────────────────
export const guestSections: LocalSection[] = [
// Dev Setup sections
{ id: 'sec-ds-1', guideId: 'guide-dev-setup', title: 'Homebrew & Basics', order: 0 },
{ id: 'sec-ds-2', guideId: 'guide-dev-setup', title: 'Node.js & npm', order: 1 },
{ id: 'sec-ds-3', guideId: 'guide-dev-setup', title: 'Git konfigurieren', order: 2 },
// Git guide sections
{ id: 'sec-git-1', guideId: 'guide-git', title: 'Repository & Commits', order: 0 },
{ id: 'sec-git-2', guideId: 'guide-git', title: 'Branches & Merging', order: 1 },
{ id: 'sec-git-3', guideId: 'guide-git', title: 'Remote & GitHub', order: 2 },
];
// ─── Steps ──────────────────────────────────────────────────
export const guestSteps: LocalStep[] = [
// ── Dev Setup: Homebrew ──────────────────────────────────
{
id: 'step-ds-1',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-1',
order: 0,
title: 'Homebrew installieren',
content:
'Öffne das Terminal und führe folgenden Befehl aus:\n\n```bash\n/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"\n```',
type: 'code',
checkable: true,
},
{
id: 'step-ds-2',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-1',
order: 1,
title: 'Installation prüfen',
content: 'Führe `brew --version` aus. Du solltest eine Versionsnummer sehen.',
type: 'checkpoint',
checkable: true,
},
{
id: 'step-ds-3',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-2',
order: 0,
title: 'Node.js via nvm installieren',
content: '```bash\nbrew install nvm\nnvm install --lts\nnvm use --lts\n```',
type: 'code',
checkable: true,
},
{
id: 'step-ds-4',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-2',
order: 1,
title: 'pnpm global installieren',
content: '```bash\nnpm install -g pnpm\n```',
type: 'instruction',
checkable: true,
},
{
id: 'step-ds-5',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-3',
order: 0,
title: 'Git-Identität setzen',
content:
'```bash\ngit config --global user.name "Dein Name"\ngit config --global user.email "dein@email.de"\n```',
type: 'code',
checkable: true,
},
{
id: 'step-ds-6',
guideId: 'guide-dev-setup',
sectionId: 'sec-ds-3',
order: 1,
title: 'SSH-Key für GitHub erstellen',
content:
'```bash\nssh-keygen -t ed25519 -C "dein@email.de"\ncat ~/.ssh/id_ed25519.pub\n```\n\nKopiere den Output und füge ihn in GitHub unter Settings → SSH Keys ein.',
type: 'tip',
checkable: true,
},
// ── Pasta steps (no sections) ────────────────────────────
{
id: 'step-pasta-1',
guideId: 'guide-pasta',
order: 0,
title: 'Auberginen salzen',
content:
'Auberginen in 1 cm Würfel schneiden, mit Salz bestreuen, 20 Minuten ziehen lassen. Dann abwaschen und trockentupfen.',
type: 'instruction',
checkable: true,
},
{
id: 'step-pasta-2',
guideId: 'guide-pasta',
order: 1,
title: 'Tomatensauce kochen',
content:
'Knoblauch in Olivenöl anschwitzen, Dosentomaten dazu, 15 Minuten köcheln lassen. Mit Salz, Pfeffer und Basilikum abschmecken.',
type: 'instruction',
checkable: true,
},
{
id: 'step-pasta-3',
guideId: 'guide-pasta',
order: 2,
title: 'Auberginen frittieren',
content: 'Reichlich Olivenöl erhitzen (180°C), Auberginen goldbraun frittieren, auf Küchenpapier abtropfen lassen.',
type: 'warning',
checkable: true,
},
{
id: 'step-pasta-4',
guideId: 'guide-pasta',
order: 3,
title: 'Pasta al dente kochen',
content: 'Rigatoni oder Penne in gut gesalzenem Wasser 1 Minute kürzer als Packungsanweisung kochen.',
type: 'tip',
checkable: true,
},
{
id: 'step-pasta-5',
guideId: 'guide-pasta',
order: 4,
title: 'Alles vereinen & servieren',
content:
'Pasta mit der Sauce vermengen, Auberginen darüber geben. Mit gesalzenem Ricotta und frischem Basilikum toppen.',
type: 'checkpoint',
checkable: false,
},
// ── Git sections ─────────────────────────────────────────
{
id: 'step-git-1',
guideId: 'guide-git',
sectionId: 'sec-git-1',
order: 0,
title: 'Repository initialisieren',
content: '```bash\nmkdir mein-projekt && cd mein-projekt\ngit init\n```',
type: 'code',
checkable: true,
},
{
id: 'step-git-2',
guideId: 'guide-git',
sectionId: 'sec-git-1',
order: 1,
title: 'Ersten Commit erstellen',
content:
'```bash\necho "# Mein Projekt" > README.md\ngit add README.md\ngit commit -m "feat: initial commit"\n```',
type: 'code',
checkable: true,
},
{
id: 'step-git-3',
guideId: 'guide-git',
sectionId: 'sec-git-2',
order: 0,
title: 'Feature-Branch erstellen',
content: '```bash\ngit checkout -b feature/mein-feature\n```',
type: 'instruction',
checkable: true,
},
{
id: 'step-git-4',
guideId: 'guide-git',
sectionId: 'sec-git-3',
order: 0,
title: 'Remote hinzufügen & pushen',
content:
'```bash\ngit remote add origin git@github.com:user/repo.git\ngit push -u origin main\n```',
type: 'code',
checkable: true,
},
];
// ─── Collections ────────────────────────────────────────────
export const guestCollections: LocalCollection[] = [
{
id: 'col-dev',
title: 'Developer Starter Kit',
description: 'Alles was du als neuer Entwickler einrichten und lernen musst.',
coverEmoji: '🚀',
coverColor: '#0d9488',
type: 'path',
guideOrder: ['guide-dev-setup', 'guide-git'],
},
];

View file

@ -0,0 +1,120 @@
/**
* Guides App Local-First Data Layer
*
* 5 Collections: guides, sections, steps, collections, runs
* All data lives in IndexedDB first, syncs to server in the background.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestGuides, guestSections, guestSteps, guestCollections } from './guest-seed.js';
// ─── Types ──────────────────────────────────────────────────
export type Difficulty = 'easy' | 'medium' | 'hard';
export type StepType = 'instruction' | 'warning' | 'tip' | 'checkpoint' | 'code';
export interface LocalGuide extends BaseRecord {
title: string;
description?: string;
/** Single emoji used as cover when no image */
coverEmoji?: string;
/** Tailwind-compatible hex color for the cover background */
coverColor?: string;
category: string;
difficulty: Difficulty;
estimatedMinutes?: number;
tags: string[];
/** Optional: belongs to a collection */
collectionId?: string;
orderInCollection?: number;
/** Optional skilltree integration */
xpReward?: number;
skillId?: string;
}
export interface LocalSection extends BaseRecord {
guideId: string;
title: string;
order: number;
}
export interface LocalStep extends BaseRecord {
guideId: string;
sectionId?: string;
order: number;
title: string;
/** Markdown content */
content?: string;
type: StepType;
checkable: boolean;
}
export interface LocalCollection extends BaseRecord {
title: string;
description?: string;
coverEmoji?: string;
coverColor?: string;
/** path = ordered learning path, library = unordered recipe book */
type: 'path' | 'library';
/** Ordered guide IDs (relevant for 'path' type) */
guideOrder: string[];
}
export interface StepState {
done: boolean;
doneAt?: string;
notes?: string;
}
export interface LocalRun extends BaseRecord {
guideId: string;
startedAt: string;
completedAt?: string;
/** scroll = all steps visible, focus = one step at a time */
mode: 'scroll' | 'focus';
stepStates: Record<string, StepState>;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const guidesStore = createLocalStore({
appId: 'guides',
collections: [
{
name: 'guides',
indexes: ['category', 'difficulty', 'collectionId', 'tags'],
guestSeed: guestGuides,
},
{
name: 'sections',
indexes: ['guideId', 'order'],
guestSeed: guestSections,
},
{
name: 'steps',
indexes: ['guideId', 'sectionId', 'order', '[guideId+order]'],
guestSeed: guestSteps,
},
{
name: 'collections',
indexes: [],
guestSeed: guestCollections,
},
{
name: 'runs',
indexes: ['guideId', 'startedAt', 'completedAt'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const guideCollection = guidesStore.collection<LocalGuide>('guides');
export const sectionCollection = guidesStore.collection<LocalSection>('sections');
export const stepCollection = guidesStore.collection<LocalStep>('steps');
export const collectionCollection = guidesStore.collection<LocalCollection>('collections');
export const runCollection = guidesStore.collection<LocalRun>('runs');

View file

@ -0,0 +1,3 @@
import { createAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createAuthStore();

View file

@ -0,0 +1,135 @@
/**
* Guides store mutation layer for guides, sections, steps, and collections.
* Reads happen via Dexie liveQuery in components.
*/
import {
guideCollection,
sectionCollection,
stepCollection,
collectionCollection,
type LocalGuide,
type LocalSection,
type LocalStep,
type LocalCollection,
} from '$lib/data/local-store.js';
// ─── Error state ─────────────────────────────────────────────
let error = $state<string | null>(null);
async function withErrorHandling<T>(fn: () => Promise<T>, msg: string): Promise<T | null> {
try {
return await fn();
} catch (e) {
error = msg;
console.error(msg, e);
return null;
}
}
// ─── Guides ──────────────────────────────────────────────────
export const guidesStore = {
get error() {
return error;
},
clearError() {
error = null;
},
async createGuide(
data: Omit<LocalGuide, keyof import('@manacore/local-store').BaseRecord>
): Promise<LocalGuide | null> {
return withErrorHandling(async () => {
return guideCollection.insert({
id: crypto.randomUUID(),
...data,
tags: data.tags ?? [],
});
}, 'Guide konnte nicht erstellt werden');
},
async updateGuide(id: string, data: Partial<LocalGuide>): Promise<LocalGuide | null> {
return withErrorHandling(
() => guideCollection.update(id, data),
'Guide konnte nicht aktualisiert werden'
);
},
async deleteGuide(id: string): Promise<void> {
await withErrorHandling(async () => {
// Soft-delete guide + cascade to sections/steps
await guideCollection.delete(id);
const sections = await sectionCollection.getAll({ guideId: id });
for (const s of sections) await sectionCollection.delete(s.id);
const steps = await stepCollection.getAll({ guideId: id });
for (const s of steps) await stepCollection.delete(s.id);
}, 'Guide konnte nicht gelöscht werden');
},
// ─── Sections ──────────────────────────────────────────
async createSection(data: Omit<LocalSection, keyof import('@manacore/local-store').BaseRecord>): Promise<LocalSection | null> {
return withErrorHandling(
() => sectionCollection.insert({ id: crypto.randomUUID(), ...data }),
'Abschnitt konnte nicht erstellt werden'
);
},
async updateSection(id: string, data: Partial<LocalSection>): Promise<LocalSection | null> {
return withErrorHandling(
() => sectionCollection.update(id, data),
'Abschnitt konnte nicht aktualisiert werden'
);
},
async deleteSection(id: string): Promise<void> {
await withErrorHandling(async () => {
await sectionCollection.delete(id);
const steps = await stepCollection.getAll({ sectionId: id });
for (const s of steps) await stepCollection.delete(s.id);
}, 'Abschnitt konnte nicht gelöscht werden');
},
// ─── Steps ─────────────────────────────────────────────
async createStep(data: Omit<LocalStep, keyof import('@manacore/local-store').BaseRecord>): Promise<LocalStep | null> {
return withErrorHandling(
() => stepCollection.insert({ id: crypto.randomUUID(), ...data }),
'Schritt konnte nicht erstellt werden'
);
},
async updateStep(id: string, data: Partial<LocalStep>): Promise<LocalStep | null> {
return withErrorHandling(
() => stepCollection.update(id, data),
'Schritt konnte nicht aktualisiert werden'
);
},
async deleteStep(id: string): Promise<void> {
await withErrorHandling(
() => stepCollection.delete(id),
'Schritt konnte nicht gelöscht werden'
);
},
// ─── Collections ───────────────────────────────────────
async createCollection(
data: Omit<LocalCollection, keyof import('@manacore/local-store').BaseRecord>
): Promise<LocalCollection | null> {
return withErrorHandling(
() => collectionCollection.insert({ id: crypto.randomUUID(), ...data }),
'Sammlung konnte nicht erstellt werden'
);
},
async updateCollection(id: string, data: Partial<LocalCollection>): Promise<LocalCollection | null> {
return withErrorHandling(
() => collectionCollection.update(id, data),
'Sammlung konnte nicht aktualisiert werden'
);
},
};

View file

@ -0,0 +1,87 @@
/**
* Runs store create, update, and complete guide runs.
*/
import { runCollection, type LocalRun, type StepState } from '$lib/data/local-store.js';
let error = $state<string | null>(null);
async function withErrorHandling<T>(fn: () => Promise<T>, msg: string): Promise<T | null> {
try {
return await fn();
} catch (e) {
error = msg;
console.error(msg, e);
return null;
}
}
export const runsStore = {
get error() {
return error;
},
clearError() {
error = null;
},
async startRun(guideId: string, mode: LocalRun['mode'] = 'scroll'): Promise<LocalRun | null> {
return withErrorHandling(
() =>
runCollection.insert({
id: crypto.randomUUID(),
guideId,
startedAt: new Date().toISOString(),
mode,
stepStates: {},
}),
'Run konnte nicht gestartet werden'
);
},
async setStepState(runId: string, stepId: string, state: Partial<StepState>): Promise<void> {
await withErrorHandling(async () => {
const run = await runCollection.get(runId);
if (!run) return;
const existing = run.stepStates[stepId] ?? { done: false };
const updated: Record<string, StepState> = {
...run.stepStates,
[stepId]: { ...existing, ...state },
};
await runCollection.update(runId, { stepStates: updated });
}, 'Schritt-Status konnte nicht aktualisiert werden');
},
async completeRun(runId: string): Promise<void> {
await withErrorHandling(
() => runCollection.update(runId, { completedAt: new Date().toISOString() }),
'Run konnte nicht abgeschlossen werden'
);
},
async deleteRun(runId: string): Promise<void> {
await withErrorHandling(
() => runCollection.delete(runId),
'Run konnte nicht gelöscht werden'
);
},
/** Returns the most recent incomplete run for a guide, or null */
async getActiveRun(guideId: string): Promise<LocalRun | null> {
try {
const runs = await runCollection.getAll({ guideId });
return runs.find((r) => !r.completedAt) ?? null;
} catch {
return null;
}
},
/** Count completed runs for a guide */
async getRunCount(guideId: string): Promise<number> {
try {
const runs = await runCollection.getAll({ guideId });
return runs.filter((r) => r.completedAt).length;
} catch {
return 0;
}
},
};

View file

@ -0,0 +1,118 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, setContext } from 'svelte';
import { AuthGate } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { guidesStore } from '$lib/stores/guides.svelte';
import { guidesStore as localStore } from '$lib/data/local-store.js';
import { getPillAppItems } from '@manacore/shared-branding';
import { BookOpen, StackSimple, ClockCounterClockwise, Plus } from '@manacore/shared-icons';
let { children } = $props();
// Context for child pages
let showCreateModal = $state(false);
setContext('openCreateGuide', () => { showCreateModal = true; });
// Nav items
const navItems = [
{ href: '/', icon: BookOpen, label: 'Bibliothek' },
{ href: '/collections', icon: StackSimple, label: 'Sammlungen' },
{ href: '/history', icon: ClockCounterClockwise, label: 'Verlauf' },
];
let currentPath = $derived($page.url.pathname);
let isActive = (href: string) =>
href === '/' ? currentPath === '/' : currentPath.startsWith(href);
onMount(async () => {
await localStore.initialize();
if (authStore.isLoggedIn) {
localStore.startSync(() => authStore.getAccessToken());
}
});
</script>
<AuthGate requiredTier="beta" allowGuest={true}>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar (desktop) -->
<aside class="hidden w-56 flex-shrink-0 flex-col border-r border-border bg-surface md:flex">
<div class="flex items-center gap-2 px-4 py-5">
<span class="text-xl">📖</span>
<span class="text-lg font-semibold text-foreground">Guides</span>
</div>
<nav class="flex flex-1 flex-col gap-1 px-2">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors
{isActive(item.href)
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
<item.icon class="h-4 w-4" />
{item.label}
</a>
{/each}
</nav>
<div class="p-3">
<button
onclick={() => (showCreateModal = true)}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
>
<Plus class="h-4 w-4" />
Neue Anleitung
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex flex-1 flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto pb-20 md:pb-0">
{@render children()}
</div>
</main>
</div>
<!-- Bottom nav (mobile) -->
<nav class="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-surface md:hidden">
<div class="flex">
{#each navItems as item}
<a
href={item.href}
class="flex flex-1 flex-col items-center gap-1 py-3 text-xs transition-colors
{isActive(item.href) ? 'text-primary' : 'text-muted-foreground'}"
>
<item.icon class="h-5 w-5" />
{item.label}
</a>
{/each}
</div>
</nav>
<!-- FAB (mobile) -->
<button
onclick={() => (showCreateModal = true)}
class="fixed bottom-20 right-4 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
aria-label="Neue Anleitung erstellen"
>
<Plus class="h-6 w-6" />
</button>
</AuthGate>
{#if showCreateModal}
<!-- GuideEditModal dynamically imported to keep bundle small -->
{#await import('$lib/components/GuideEditModal.svelte') then { default: GuideEditModal }}
<GuideEditModal
open={true}
onClose={() => (showCreateModal = false)}
onSave={async (data) => {
await guidesStore.createGuide(data);
showCreateModal = false;
}}
/>
{/await}
{/if}

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { getContext } from 'svelte';
import { guideCollection, runCollection, type LocalGuide } from '$lib/data/local-store.js';
import GuideCard from '$lib/components/GuideCard.svelte';
// Filter state
let searchQuery = $state('');
let selectedCategory = $state<string | null>(null);
let selectedDifficulty = $state<string | null>(null);
// Live data
let allGuides = $state<LocalGuide[]>([]);
let runCountByGuide = $state<Record<string, number>>({});
let activeRunByGuide = $state<Record<string, boolean>>({});
$effect(() => {
const sub = liveQuery(() => guideCollection.getAll()).subscribe((guides) => {
allGuides = guides;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(() => runCollection.getAll()).subscribe((runs) => {
const counts: Record<string, number> = {};
const active: Record<string, boolean> = {};
for (const run of runs) {
if (run.completedAt) counts[run.guideId] = (counts[run.guideId] ?? 0) + 1;
else active[run.guideId] = true;
}
runCountByGuide = counts;
activeRunByGuide = active;
});
return () => sub.unsubscribe();
});
// Derived filtered guides
let categories = $derived([...new Set(allGuides.map((g) => g.category))].sort());
let filteredGuides = $derived(
allGuides.filter((g) => {
if (searchQuery && !g.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
if (selectedCategory && g.category !== selectedCategory) return false;
if (selectedDifficulty && g.difficulty !== selectedDifficulty) return false;
return true;
})
);
const openCreateGuide = getContext<() => void>('openCreateGuide');
const difficultyLabels = { easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' };
</script>
<div class="p-4 md:p-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Bibliothek</h1>
<p class="text-sm text-muted-foreground">{allGuides.length} Anleitungen</p>
</div>
<button
onclick={openCreateGuide}
class="hidden rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover md:flex items-center gap-2"
>
+ Neue Anleitung
</button>
</div>
<!-- Search & Filters -->
<div class="mb-6 flex flex-wrap gap-3">
<input
type="search"
placeholder="Suchen..."
bind:value={searchQuery}
class="h-9 rounded-lg border border-border bg-surface px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 min-w-[180px]"
/>
<div class="flex gap-2 flex-wrap">
<!-- Category filter -->
{#each categories as cat}
<button
onclick={() => (selectedCategory = selectedCategory === cat ? null : cat)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors
{selectedCategory === cat
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground hover:bg-accent'}"
>
{cat}
</button>
{/each}
<!-- Difficulty filter -->
{#each Object.entries(difficultyLabels) as [val, label]}
<button
onclick={() => (selectedDifficulty = selectedDifficulty === val ? null : val)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors
{selectedDifficulty === val
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground hover:bg-accent'}"
>
{label}
</button>
{/each}
</div>
</div>
<!-- Guide grid -->
{#if filteredGuides.length === 0}
<div class="flex flex-col items-center justify-center py-24 text-center">
{#if allGuides.length === 0}
<span class="mb-4 text-6xl">📖</span>
<h2 class="mb-2 text-lg font-semibold text-foreground">Noch keine Anleitungen</h2>
<p class="mb-6 text-sm text-muted-foreground">
Erstelle deine erste Anleitung — ein Rezept, eine SOP, ein Lernpfad.
</p>
<button
onclick={openCreateGuide}
class="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Erste Anleitung erstellen
</button>
{:else}
<span class="mb-3 text-4xl">🔍</span>
<p class="text-sm text-muted-foreground">Keine Ergebnisse für diese Filter.</p>
<button
onclick={() => {
searchQuery = '';
selectedCategory = null;
selectedDifficulty = null;
}}
class="mt-3 text-sm text-primary hover:underline"
>
Filter zurücksetzen
</button>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each filteredGuides as guide (guide.id)}
<GuideCard
{guide}
runCount={runCountByGuide[guide.id] ?? 0}
hasActiveRun={activeRunByGuide[guide.id] ?? false}
/>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { collectionCollection, guideCollection, runCollection } from '$lib/data/local-store.js';
import type { LocalCollection, LocalGuide, LocalRun } from '$lib/data/local-store.js';
let collections = $state<LocalCollection[]>([]);
let allGuides = $state<LocalGuide[]>([]);
let allRuns = $state<LocalRun[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
const [cols, guides, runs] = await Promise.all([
collectionCollection.getAll(),
guideCollection.getAll(),
runCollection.getAll(),
]);
return { cols, guides, runs };
}).subscribe(({ cols, guides, runs }) => {
collections = cols;
allGuides = guides;
allRuns = runs;
});
return () => sub.unsubscribe();
});
function getGuidesForCollection(col: LocalCollection): LocalGuide[] {
if (col.type === 'path') {
return col.guideOrder
.map((id) => allGuides.find((g) => g.id === id))
.filter(Boolean) as LocalGuide[];
}
return allGuides.filter((g) => g.collectionId === col.id);
}
function getPathProgress(col: LocalCollection): number {
const guides = getGuidesForCollection(col);
if (guides.length === 0) return 0;
const completed = guides.filter((g) =>
allRuns.some((r) => r.guideId === g.id && r.completedAt)
).length;
return Math.round((completed / guides.length) * 100);
}
</script>
<div class="p-4 md:p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Sammlungen</h1>
<p class="text-sm text-muted-foreground">Lernpfade und thematische Anleitungs-Sets</p>
</div>
{#if collections.length === 0}
<div class="flex flex-col items-center justify-center py-24 text-center">
<span class="mb-4 text-6xl">📂</span>
<h2 class="mb-2 text-lg font-semibold">Noch keine Sammlungen</h2>
<p class="text-sm text-muted-foreground max-w-sm">
Sammlungen gruppieren Anleitungen zu Lernpfaden oder thematischen Bibliotheken.
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each collections as col (col.id)}
{@const guides = getGuidesForCollection(col)}
{@const progress = col.type === 'path' ? getPathProgress(col) : null}
<a
href="/collections/{col.id}"
class="group block rounded-2xl border border-border bg-surface p-5 transition-all hover:border-primary/30 hover:shadow-md"
>
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl text-2xl"
style="background-color: {col.coverColor ?? '#0d9488'}20"
>
{col.coverEmoji ?? (col.type === 'path' ? '🗺' : '📚')}
</div>
<div class="flex-1 min-w-0">
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{col.title}
</h3>
<span class="text-xs text-muted-foreground">
{col.type === 'path' ? '🗺 Lernpfad' : '📚 Bibliothek'} · {guides.length} Anleitungen
</span>
</div>
</div>
{#if col.description}
<p class="mb-3 text-xs text-muted-foreground line-clamp-2">{col.description}</p>
{/if}
{#if progress !== null}
<div>
<div class="mb-1 flex items-center justify-between text-xs">
<span class="text-muted-foreground">Fortschritt</span>
<span class="font-medium text-primary">{progress}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
style="width: {progress}%"
></div>
</div>
</div>
{/if}
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,252 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import {
guideCollection,
sectionCollection,
stepCollection,
runCollection,
type LocalGuide,
type LocalSection,
type LocalStep,
type LocalRun,
} from '$lib/data/local-store.js';
import { runsStore } from '$lib/stores/runs.svelte';
import { guidesStore } from '$lib/stores/guides.svelte';
let guideId = $derived($page.params.id);
let guide = $state<LocalGuide | null>(null);
let sections = $state<LocalSection[]>([]);
let steps = $state<LocalStep[]>([]);
let runs = $state<LocalRun[]>([]);
let showEditModal = $state(false);
let showDeleteConfirm = $state(false);
$effect(() => {
const id = guideId;
const sub = liveQuery(async () => {
const [g, sects, stps, rs] = await Promise.all([
guideCollection.get(id),
sectionCollection.getAll({ guideId: id }),
stepCollection.getAll({ guideId: id }),
runCollection.getAll({ guideId: id }),
]);
return { g, sects, stps, rs };
}).subscribe(({ g, sects, stps, rs }) => {
guide = g ?? null;
sections = sects.sort((a, b) => a.order - b.order);
steps = stps.sort((a, b) => a.order - b.order);
runs = rs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
});
return () => sub.unsubscribe();
});
let completedRuns = $derived(runs.filter((r) => r.completedAt));
let activeRun = $derived(runs.find((r) => !r.completedAt));
let totalSteps = $derived(steps.filter((s) => s.checkable).length);
function getStepsForSection(sectionId: string) {
return steps.filter((s) => s.sectionId === sectionId);
}
function getUnsectionedSteps() {
return steps.filter((s) => !s.sectionId);
}
function getActiveRunProgress() {
if (!activeRun) return 0;
const done = Object.values(activeRun.stepStates).filter((s) => s.done).length;
return totalSteps > 0 ? Math.round((done / totalSteps) * 100) : 0;
}
async function startRun(mode: 'scroll' | 'focus') {
const run = await runsStore.startRun(guideId, mode);
if (run) goto(`/guide/${guideId}/run?runId=${run.id}&mode=${mode}`);
}
async function continueRun() {
if (activeRun) goto(`/guide/${guideId}/run?runId=${activeRun.id}&mode=${activeRun.mode}`);
}
const difficultyConfig = {
easy: { label: 'Einfach', color: 'text-green-600 bg-green-50' },
medium: { label: 'Mittel', color: 'text-amber-600 bg-amber-50' },
hard: { label: 'Schwer', color: 'text-red-600 bg-red-50' },
};
const stepTypeConfig = {
instruction: { icon: '→', color: 'border-l-primary' },
warning: { icon: '⚠', color: 'border-l-orange-400' },
tip: { icon: '💡', color: 'border-l-violet-400' },
checkpoint: { icon: '✓', color: 'border-l-blue-400' },
code: { icon: '</>', color: 'border-l-slate-400' },
};
</script>
{#if !guide}
<div class="flex h-full items-center justify-center">
<p class="text-muted-foreground">Anleitung nicht gefunden.</p>
</div>
{:else}
<div class="mx-auto max-w-3xl p-4 md:p-8">
<!-- Back -->
<a href="/" class="mb-6 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
← Bibliothek
</a>
<!-- Cover header -->
<div
class="mb-6 flex items-center gap-4 rounded-2xl p-6"
style="background-color: {guide.coverColor ?? '#0d9488'}20"
>
<span class="text-5xl">{guide.coverEmoji ?? '📖'}</span>
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-foreground">{guide.title}</h1>
{#if guide.description}
<p class="mt-1 text-sm text-muted-foreground">{guide.description}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {difficultyConfig[guide.difficulty].color}">
{difficultyConfig[guide.difficulty].label}
</span>
{#if guide.estimatedMinutes}
<span class="text-xs text-muted-foreground">{guide.estimatedMinutes} min</span>
{/if}
<span class="text-xs text-muted-foreground">{totalSteps} Schritte</span>
{#if completedRuns.length > 0}
<span class="text-xs text-muted-foreground">{completedRuns.length}× abgeschlossen</span>
{/if}
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => (showEditModal = true)}
class="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Bearbeiten"
>✏️</button>
</div>
</div>
<!-- Active run banner -->
{#if activeRun}
<div class="mb-6 rounded-xl border border-primary/30 bg-primary/5 p-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-primary">Aktiver Durchlauf</span>
<span class="text-xs text-muted-foreground">{getActiveRunProgress()}% abgeschlossen</span>
</div>
<div class="mb-3 h-1.5 overflow-hidden rounded-full bg-primary/20">
<div
class="h-full rounded-full bg-primary transition-all"
style="width: {getActiveRunProgress()}%"
></div>
</div>
<button
onclick={continueRun}
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Fortsetzen →
</button>
</div>
{:else}
<!-- Start run buttons -->
<div class="mb-8 flex gap-3">
<button
onclick={() => startRun('scroll')}
class="flex-1 rounded-xl bg-primary px-4 py-3 text-sm font-semibold text-white hover:bg-primary-hover"
>
▶ Durchlauf starten
</button>
<button
onclick={() => startRun('focus')}
class="rounded-xl border border-border px-4 py-3 text-sm font-medium text-foreground hover:bg-accent"
title="Fokus-Modus: ein Schritt auf einmal"
>
🎯 Fokus
</button>
</div>
{/if}
<!-- Steps -->
<div class="space-y-6">
{#if sections.length > 0}
{#each sections as section (section.id)}
<div>
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
<div class="space-y-2">
{#each getStepsForSection(section.id) as step (step.id)}
{@const cfg = stepTypeConfig[step.type]}
<div class="rounded-lg border-l-2 bg-surface px-4 py-3 {cfg.color}">
<div class="flex items-start gap-2">
<span class="mt-0.5 text-xs font-mono text-muted-foreground">{cfg.icon}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">{step.title}</p>
{#if step.content}
<p class="mt-1 text-xs text-muted-foreground whitespace-pre-wrap">{step.content}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
{#if getUnsectionedSteps().length > 0}
<div class="space-y-2">
{#each getUnsectionedSteps() as step (step.id)}
{@const cfg = stepTypeConfig[step.type]}
<div class="rounded-lg border-l-2 bg-surface px-4 py-3 {cfg.color}">
<p class="text-sm font-medium text-foreground">{step.title}</p>
</div>
{/each}
</div>
{/if}
{:else}
<div class="space-y-2">
{#each steps as step (step.id)}
{@const cfg = stepTypeConfig[step.type]}
<div class="rounded-lg border-l-2 bg-surface px-4 py-3 {cfg.color}">
<div class="flex items-start gap-2">
<span class="mt-0.5 text-xs font-mono text-muted-foreground">{cfg.icon}</span>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">{step.title}</p>
{#if step.content}
<p class="mt-1 text-xs text-muted-foreground whitespace-pre-wrap">{step.content}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Run history -->
{#if completedRuns.length > 0}
<div class="mt-10">
<h2 class="mb-3 text-sm font-semibold text-foreground">Verlauf</h2>
<div class="space-y-2">
{#each completedRuns.slice(0, 5) as run (run.id)}
<div class="flex items-center justify-between rounded-lg bg-surface px-4 py-2.5 text-sm">
<div class="flex items-center gap-2">
<span class="text-green-500"></span>
<span class="text-foreground">
{new Date(run.startedAt).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})}
</span>
</div>
<span class="text-xs text-muted-foreground">
{Object.values(run.stepStates).filter((s) => s.done).length}/{totalSteps} Schritte
</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import {
guideCollection,
sectionCollection,
stepCollection,
runCollection,
type LocalGuide,
type LocalStep,
type LocalRun,
} from '$lib/data/local-store.js';
import { runsStore } from '$lib/stores/runs.svelte';
let guideId = $derived($page.params.id);
let runId = $derived($page.url.searchParams.get('runId') ?? '');
let mode = $derived(($page.url.searchParams.get('mode') ?? 'scroll') as 'scroll' | 'focus');
let guide = $state<LocalGuide | null>(null);
let steps = $state<LocalStep[]>([]);
let run = $state<LocalRun | null>(null);
let focusIndex = $state(0);
let showNoteInput = $state(false);
let noteText = $state('');
$effect(() => {
const id = guideId;
const rId = runId;
const sub = liveQuery(async () => {
const [g, stps, r] = await Promise.all([
guideCollection.get(id),
stepCollection.getAll({ guideId: id }),
runCollection.get(rId),
]);
return { g, stps, r };
}).subscribe(({ g, stps, r }) => {
guide = g ?? null;
steps = stps.filter((s) => s.checkable).sort((a, b) => a.order - b.order);
run = r ?? null;
});
return () => sub.unsubscribe();
});
function isStepDone(stepId: string) {
return run?.stepStates[stepId]?.done ?? false;
}
let doneCount = $derived(steps.filter((s) => isStepDone(s.id)).length);
let progress = $derived(steps.length > 0 ? Math.round((doneCount / steps.length) * 100) : 0);
let isComplete = $derived(doneCount === steps.length && steps.length > 0);
async function toggleStep(stepId: string) {
if (!runId) return;
const current = isStepDone(stepId);
await runsStore.setStepState(runId, stepId, {
done: !current,
doneAt: !current ? new Date().toISOString() : undefined,
});
if (!current && mode === 'focus' && focusIndex < steps.length - 1) {
focusIndex++;
}
}
async function saveNote() {
if (!runId || !steps[focusIndex]) return;
await runsStore.setStepState(runId, steps[focusIndex].id, { notes: noteText });
showNoteInput = false;
noteText = '';
}
async function finishRun() {
if (!runId) return;
await runsStore.completeRun(runId);
goto(`/guide/${guideId}`);
}
function exitRun() {
goto(`/guide/${guideId}`);
}
const stepTypeIcons: Record<string, string> = {
instruction: '→',
warning: '⚠',
tip: '💡',
checkpoint: '✓',
code: '</>',
};
</script>
{#if !guide || !run}
<div class="flex h-screen items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
</div>
{:else if mode === 'scroll'}
<!-- ── Scroll mode ───────────────────────────────────────── -->
<div class="mx-auto max-w-2xl p-4 md:p-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<button onclick={exitRun} class="text-sm text-muted-foreground hover:text-foreground">
{guide.title}
</button>
<span class="text-sm text-muted-foreground">{doneCount}/{steps.length} · {progress}%</span>
</div>
<!-- Progress bar -->
<div class="mb-6 h-1.5 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-300" style="width: {progress}%"></div>
</div>
<!-- Steps -->
<div class="space-y-3">
{#each steps as step, i (step.id)}
{@const done = isStepDone(step.id)}
<button
onclick={() => toggleStep(step.id)}
class="w-full rounded-xl border p-4 text-left transition-all
{done
? 'border-primary/30 bg-primary/5'
: 'border-border bg-surface hover:border-primary/30 hover:bg-accent/30'}"
>
<div class="flex items-start gap-3">
<!-- Checkbox -->
<div
class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 transition-colors
{done ? 'border-primary bg-primary' : 'border-border'}"
>
{#if done}
<span class="text-xs text-white"></span>
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{stepTypeIcons[step.type]}</span>
<span class="text-sm font-medium {done ? 'line-through text-muted-foreground' : 'text-foreground'}">
{step.title}
</span>
</div>
{#if step.content && !done}
<p class="mt-1.5 text-xs text-muted-foreground whitespace-pre-wrap">{step.content}</p>
{/if}
</div>
</div>
</button>
{/each}
</div>
<!-- Complete button -->
{#if isComplete}
<div class="mt-8 text-center">
<div class="mb-4 text-5xl">🎉</div>
<p class="mb-2 text-lg font-semibold text-foreground">Alle Schritte erledigt!</p>
<p class="mb-6 text-sm text-muted-foreground">Möchtest du den Durchlauf abschließen?</p>
<button
onclick={finishRun}
class="rounded-xl bg-primary px-8 py-3 text-sm font-semibold text-white hover:bg-primary-hover"
>
Durchlauf abschließen
</button>
</div>
{/if}
</div>
{:else}
<!-- ── Focus mode ────────────────────────────────────────── -->
{#if steps.length === 0}
<div class="flex h-screen items-center justify-center">
<p class="text-muted-foreground">Keine Schritte vorhanden.</p>
</div>
{:else}
{@const currentStep = steps[focusIndex]}
{@const done = isStepDone(currentStep.id)}
<div class="flex h-screen flex-col">
<!-- Top bar -->
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<button onclick={exitRun} class="text-sm text-muted-foreground hover:text-foreground"></button>
<div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground">
{focusIndex + 1} / {steps.length}
</span>
<div class="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all" style="width: {progress}%"></div>
</div>
</div>
<span class="text-xs text-muted-foreground">{progress}%</span>
</div>
<!-- Step content -->
<div class="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div class="mb-6 text-4xl">{stepTypeIcons[currentStep.type]}</div>
<h2 class="mb-4 text-2xl font-bold text-foreground leading-snug max-w-md">
{currentStep.title}
</h2>
{#if currentStep.content}
<p class="max-w-md text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{currentStep.content}
</p>
{/if}
{#if showNoteInput}
<div class="mt-6 w-full max-w-md">
<textarea
bind:value={noteText}
placeholder="Notiz zu diesem Schritt..."
class="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
rows="3"
></textarea>
<div class="mt-2 flex gap-2">
<button onclick={saveNote} class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white">
Speichern
</button>
<button onclick={() => (showNoteInput = false)} class="rounded-lg border border-border px-4 py-2 text-sm">
Abbrechen
</button>
</div>
</div>
{/if}
</div>
<!-- Bottom actions -->
<div class="border-t border-border px-6 py-6">
{#if !done}
<button
onclick={() => toggleStep(currentStep.id)}
class="mb-3 w-full rounded-2xl bg-primary py-4 text-base font-semibold text-white hover:bg-primary-hover active:scale-[0.98] transition-transform"
>
✓ Erledigt
</button>
{:else}
<div class="mb-3 flex items-center justify-center gap-2 rounded-2xl bg-primary/10 py-4">
<span class="text-primary font-medium">✓ Erledigt</span>
</div>
{/if}
<div class="flex items-center justify-between">
<button
onclick={() => (focusIndex = Math.max(0, focusIndex - 1))}
disabled={focusIndex === 0}
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground disabled:opacity-30"
>
← Zurück
</button>
<button
onclick={() => (showNoteInput = !showNoteInput)}
class="text-xs text-muted-foreground hover:text-foreground"
>
📝 Notiz
</button>
{#if focusIndex < steps.length - 1}
<button
onclick={() => (focusIndex = focusIndex + 1)}
class="rounded-lg px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Weiter →
</button>
{:else if isComplete}
<button
onclick={finishRun}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
>
Abschließen 🎉
</button>
{:else}
<button
onclick={finishRun}
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Beenden
</button>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { runCollection, guideCollection } from '$lib/data/local-store.js';
import type { LocalRun, LocalGuide } from '$lib/data/local-store.js';
import { runsStore } from '$lib/stores/runs.svelte';
let runs = $state<LocalRun[]>([]);
let guides = $state<Map<string, LocalGuide>>(new Map());
$effect(() => {
const sub = liveQuery(async () => {
const [rs, gs] = await Promise.all([runCollection.getAll(), guideCollection.getAll()]);
return { rs, gs };
}).subscribe(({ rs, gs }) => {
runs = rs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
guides = new Map(gs.map((g) => [g.id, g]));
});
return () => sub.unsubscribe();
});
let completedRuns = $derived(runs.filter((r) => r.completedAt));
let activeRuns = $derived(runs.filter((r) => !r.completedAt));
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getDuration(run: LocalRun): string {
if (!run.completedAt) return 'Laufend';
const ms = new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime();
const min = Math.round(ms / 60000);
return min < 60 ? `${min} min` : `${Math.floor(min / 60)}h ${min % 60}min`;
}
function getStepCount(run: LocalRun): { done: number; total: number } {
const done = Object.values(run.stepStates).filter((s) => s.done).length;
return { done, total: Object.keys(run.stepStates).length };
}
</script>
<div class="p-4 md:p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Verlauf</h1>
<p class="text-sm text-muted-foreground">{completedRuns.length} abgeschlossene Durchläufe</p>
</div>
<!-- Active runs -->
{#if activeRuns.length > 0}
<div class="mb-8">
<h2 class="mb-3 text-sm font-semibold text-foreground">Aktive Durchläufe</h2>
<div class="space-y-2">
{#each activeRuns as run (run.id)}
{@const guide = guides.get(run.guideId)}
{@const { done, total } = getStepCount(run)}
<a
href="/guide/{run.guideId}/run?runId={run.id}&mode={run.mode}"
class="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 hover:bg-primary/10 transition-colors"
>
<span class="text-2xl">{guide?.coverEmoji ?? '📖'}</span>
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-foreground">{guide?.title ?? 'Unbekannte Anleitung'}</p>
<p class="text-xs text-muted-foreground">
Gestartet {formatDate(run.startedAt)} · {done} von {total} Schritten
</p>
</div>
<span class="text-sm text-primary">Fortsetzen →</span>
</a>
{/each}
</div>
</div>
{/if}
<!-- Completed runs -->
{#if completedRuns.length === 0}
<div class="flex flex-col items-center justify-center py-24 text-center">
<span class="mb-4 text-6xl">🕐</span>
<h2 class="mb-2 text-lg font-semibold">Noch keine abgeschlossenen Durchläufe</h2>
<p class="text-sm text-muted-foreground">
Starte einen Durchlauf einer Anleitung, um hier den Verlauf zu sehen.
</p>
</div>
{:else}
<div class="space-y-2">
{#each completedRuns as run (run.id)}
{@const guide = guides.get(run.guideId)}
{@const { done, total } = getStepCount(run)}
<div class="flex items-center gap-3 rounded-xl bg-surface p-4">
<span class="text-2xl">{guide?.coverEmoji ?? '📖'}</span>
<div class="flex-1 min-w-0">
<a
href="/guide/{run.guideId}"
class="block truncate font-medium text-foreground hover:text-primary"
>
{guide?.title ?? 'Unbekannte Anleitung'}
</a>
<p class="text-xs text-muted-foreground">
{formatDate(run.startedAt)} · {getDuration(run)}
</p>
</div>
<div class="text-right">
<span class="text-sm font-medium text-green-600"></span>
<p class="text-xs text-muted-foreground">{done}/{total}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
import { onMount } from 'svelte';
import { isLoading as i18nLoading } from 'svelte-i18n';
import { theme } from '@manacore/shared-theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
let loading = $state(true);
let appReady = $derived(!loading && !$i18nLoading);
onMount(() => {
theme.initialize();
authStore.initialize().then(() => {
loading = false;
});
});
</script>
<svelte:head>
<meta name="description" content="Schritt-für-Schritt Anleitungen, Playbooks und Lernpfade." />
<meta property="og:title" content="Guides - Anleitungen & Playbooks" />
<meta property="og:description" content="Erstelle und führe strukturierte Anleitungen aus." />
<meta property="og:type" content="website" />
</svelte:head>
{#if !appReady}
<div class="flex h-screen items-center justify-center bg-background">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,49 @@
/// <reference types="vitest/config" />
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA(
createPWAConfig({
name: 'Guides - Schritt-für-Schritt Anleitungen',
shortName: 'Guides',
description: 'Erstelle und führe Schritt-für-Schritt Anleitungen, Playbooks und Lernpfade aus',
themeColor: '#0d9488',
devEnabled: false,
shortcuts: [
{
name: 'Neue Anleitung',
short_name: 'Neu',
description: 'Neue Anleitung erstellen',
url: '/?action=new',
},
],
})
),
],
server: {
port: 5200,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES],
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: true,
},
define: {
...getBuildDefines(),
},
});

14
apps/guides/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "guides",
"version": "1.0.0",
"private": true,
"description": "Mana Guides - Schritt-für-Schritt Anleitungen & Playbooks",
"scripts": {
"dev": "pnpm run --filter=@guides/* --parallel dev",
"dev:web": "pnpm --filter @guides/web dev"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -134,6 +134,11 @@
"dev:calc:web": "pnpm --filter @calc/web dev",
"dev:calc:app": "pnpm dev:calc:web",
"dev:calc:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:calc:web\"",
"guides:dev": "turbo run dev --filter=guides...",
"dev:guides:web": "pnpm --filter @guides/web dev",
"dev:guides:app": "pnpm dev:guides:web",
"dev:guides:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:guides:web\"",
"dev:guides:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:guides:web\"",
"moodlit:dev": "turbo run dev --filter=moodlit...",
"dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev",
"dev:moodlit:app": "concurrently -n server,web -c yellow,cyan \"pnpm dev:moodlit:server\" \"pnpm dev:moodlit:web\"",

View file

@ -123,6 +123,9 @@ export const APP_ICONS = {
news: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
guides: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -513,6 +513,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'beta',
},
{
id: 'guides',
name: 'Guides',
description: {
de: 'Schritt-für-Schritt Anleitungen',
en: 'Step-by-Step Guides',
},
longDescription: {
de: 'Erstelle und führe strukturierte Anleitungen aus — Rezepte, SOPs, Lernpfade und Playbooks mit Ausführungshistorie.',
en: 'Create and execute structured guides — recipes, SOPs, learning paths, and playbooks with run history.',
},
icon: APP_ICONS.guides,
color: '#0d9488',
comingSoon: false,
status: 'development',
requiredTier: 'beta',
},
];
/**
@ -617,6 +634,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
news: { dev: 'http://localhost:5174', prod: 'https://news.mana.how' },
calc: { dev: 'http://localhost:5198', prod: 'https://calc.mana.how' },
guides: { dev: 'http://localhost:5200', prod: 'https://guides.mana.how' },
};
/**