mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
5e4518b418
commit
0a9c38161b
27 changed files with 2460 additions and 0 deletions
233
.claude/plans/mana-guides.md
Normal file
233
.claude/plans/mana-guides.md
Normal 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) |
|
||||
61
apps/guides/apps/web/package.json
Normal file
61
apps/guides/apps/web/package.json
Normal 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"
|
||||
}
|
||||
34
apps/guides/apps/web/src/app.css
Normal file
34
apps/guides/apps/web/src/app.css
Normal 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
2
apps/guides/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
26
apps/guides/apps/web/src/app.html
Normal file
26
apps/guides/apps/web/src/app.html
Normal 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>
|
||||
12
apps/guides/apps/web/src/hooks.client.ts
Normal file
12
apps/guides/apps/web/src/hooks.client.ts
Normal 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);
|
||||
};
|
||||
79
apps/guides/apps/web/src/lib/components/GuideCard.svelte
Normal file
79
apps/guides/apps/web/src/lib/components/GuideCard.svelte
Normal 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>
|
||||
251
apps/guides/apps/web/src/lib/components/GuideEditModal.svelte
Normal file
251
apps/guides/apps/web/src/lib/components/GuideEditModal.svelte
Normal 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}
|
||||
245
apps/guides/apps/web/src/lib/data/guest-seed.ts
Normal file
245
apps/guides/apps/web/src/lib/data/guest-seed.ts
Normal 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'],
|
||||
},
|
||||
];
|
||||
120
apps/guides/apps/web/src/lib/data/local-store.ts
Normal file
120
apps/guides/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
3
apps/guides/apps/web/src/lib/stores/auth.svelte.ts
Normal file
3
apps/guides/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
135
apps/guides/apps/web/src/lib/stores/guides.svelte.ts
Normal file
135
apps/guides/apps/web/src/lib/stores/guides.svelte.ts
Normal 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'
|
||||
);
|
||||
},
|
||||
};
|
||||
87
apps/guides/apps/web/src/lib/stores/runs.svelte.ts
Normal file
87
apps/guides/apps/web/src/lib/stores/runs.svelte.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
118
apps/guides/apps/web/src/routes/(app)/+layout.svelte
Normal file
118
apps/guides/apps/web/src/routes/(app)/+layout.svelte
Normal 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}
|
||||
149
apps/guides/apps/web/src/routes/(app)/+page.svelte
Normal file
149
apps/guides/apps/web/src/routes/(app)/+page.svelte
Normal 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>
|
||||
107
apps/guides/apps/web/src/routes/(app)/collections/+page.svelte
Normal file
107
apps/guides/apps/web/src/routes/(app)/collections/+page.svelte
Normal 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>
|
||||
252
apps/guides/apps/web/src/routes/(app)/guide/[id]/+page.svelte
Normal file
252
apps/guides/apps/web/src/routes/(app)/guide/[id]/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
114
apps/guides/apps/web/src/routes/(app)/history/+page.svelte
Normal file
114
apps/guides/apps/web/src/routes/(app)/history/+page.svelte
Normal 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>
|
||||
37
apps/guides/apps/web/src/routes/+layout.svelte
Normal file
37
apps/guides/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
14
apps/guides/apps/web/svelte.config.js
Normal file
14
apps/guides/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/guides/apps/web/tsconfig.json
Normal file
14
apps/guides/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
apps/guides/apps/web/vite.config.ts
Normal file
49
apps/guides/apps/web/vite.config.ts
Normal 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
14
apps/guides/package.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue