feat(guides): complete Phase 1 — auth routes, i18n, collection detail, import fixes

- Add auth routes: login, register, forgot-password, reset-password (teal branding)
- Add i18n setup with de/en locales
- Add version.ts and theme store
- Add collections/[id] detail page with path progress and per-guide actions
- Fix import naming conflict (guidesStore → dbStore alias in app layout)
- Fix BaseRecord inline imports → top-level imports in stores and components
- Fix authStore.getAccessToken() → getValidToken()
- Fix theme import to use local store wrapper
- Update plan doc: Phase 1 complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 21:16:16 +02:00
parent 6d2509c258
commit a893b07b70
15 changed files with 480 additions and 13 deletions

View file

@ -133,19 +133,22 @@ apps/guides/apps/web/src/routes/
## Phasen
### Phase 1 — MVP (jetzt implementiert) ✓
### Phase 1 — MVP ✅ Abgeschlossen
- [x] Monorepo-Skelett (package.json, config-files)
- [x] Local-Store (5 Collections)
- [x] Guest-Seed (3 Demo-Guides)
- [x] i18n Setup (de + en)
- [x] version.ts
- [x] theme Store
- [x] Root-Layout + Auth-Layout
- [x] Auth-Routes (login, register, forgot-password, reset-password)
- [x] Bibliothek-View (+page.svelte)
- [x] Guide-Detail-View
- [x] Run-Modus (Scroll + Fokus)
- [x] Collections-View
- [x] Collections-View + Collections-Detail [id]
- [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

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { LocalGuide, Difficulty } from '$lib/data/local-store.js';
import type { BaseRecord } from '@manacore/local-store';
type GuideInput = Omit<LocalGuide, keyof import('@manacore/local-store').BaseRecord>;
type GuideInput = Omit<LocalGuide, keyof BaseRecord>;
interface Props {
open: boolean;

View file

@ -0,0 +1,23 @@
import { addMessages, init, getLocaleFromNavigator } from 'svelte-i18n';
import de from './locales/de.json';
import en from './locales/en.json';
const LOCALE_KEY = 'guides_locale';
addMessages('de', de);
addMessages('en', en);
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(LOCALE_KEY) : null;
const locale = saved ?? getLocaleFromNavigator() ?? 'de';
init({
fallbackLocale: 'de',
initialLocale: locale.startsWith('de') ? 'de' : 'en',
});
export function setLocale(lang: 'de' | 'en') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(LOCALE_KEY, lang);
}
}

View file

@ -0,0 +1,22 @@
{
"app.name": "Guides",
"app.description": "Schritt-für-Schritt Anleitungen",
"nav.library": "Bibliothek",
"nav.collections": "Sammlungen",
"nav.history": "Verlauf",
"guide.new": "Neue Anleitung",
"guide.start_run": "Durchlauf starten",
"guide.continue_run": "Fortsetzen",
"guide.finish_run": "Durchlauf abschließen",
"guide.steps": "Schritte",
"guide.difficulty.easy": "Einfach",
"guide.difficulty.medium": "Mittel",
"guide.difficulty.hard": "Schwer",
"run.mode.scroll": "Scroll-Modus",
"run.mode.focus": "Fokus-Modus",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.delete": "Löschen",
"common.create": "Erstellen",
"common.back": "Zurück"
}

View file

@ -0,0 +1,22 @@
{
"app.name": "Guides",
"app.description": "Step-by-Step Guides",
"nav.library": "Library",
"nav.collections": "Collections",
"nav.history": "History",
"guide.new": "New Guide",
"guide.start_run": "Start Run",
"guide.continue_run": "Continue",
"guide.finish_run": "Complete Run",
"guide.steps": "Steps",
"guide.difficulty.easy": "Easy",
"guide.difficulty.medium": "Medium",
"guide.difficulty.hard": "Hard",
"run.mode.scroll": "Scroll Mode",
"run.mode.focus": "Focus Mode",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.create": "Create",
"common.back": "Back"
}

View file

@ -3,6 +3,7 @@
* Reads happen via Dexie liveQuery in components.
*/
import type { BaseRecord } from '@manacore/local-store';
import {
guideCollection,
sectionCollection,
@ -39,7 +40,7 @@ export const guidesStore = {
},
async createGuide(
data: Omit<LocalGuide, keyof import('@manacore/local-store').BaseRecord>
data: Omit<LocalGuide, keyof BaseRecord>
): Promise<LocalGuide | null> {
return withErrorHandling(async () => {
return guideCollection.insert({
@ -70,7 +71,7 @@ export const guidesStore = {
// ─── Sections ──────────────────────────────────────────
async createSection(data: Omit<LocalSection, keyof import('@manacore/local-store').BaseRecord>): Promise<LocalSection | null> {
async createSection(data: Omit<LocalSection, keyof BaseRecord>): Promise<LocalSection | null> {
return withErrorHandling(
() => sectionCollection.insert({ id: crypto.randomUUID(), ...data }),
'Abschnitt konnte nicht erstellt werden'
@ -94,7 +95,7 @@ export const guidesStore = {
// ─── Steps ─────────────────────────────────────────────
async createStep(data: Omit<LocalStep, keyof import('@manacore/local-store').BaseRecord>): Promise<LocalStep | null> {
async createStep(data: Omit<LocalStep, keyof BaseRecord>): Promise<LocalStep | null> {
return withErrorHandling(
() => stepCollection.insert({ id: crypto.randomUUID(), ...data }),
'Schritt konnte nicht erstellt werden'
@ -118,7 +119,7 @@ export const guidesStore = {
// ─── Collections ───────────────────────────────────────
async createCollection(
data: Omit<LocalCollection, keyof import('@manacore/local-store').BaseRecord>
data: Omit<LocalCollection, keyof BaseRecord>
): Promise<LocalCollection | null> {
return withErrorHandling(
() => collectionCollection.insert({ id: crypto.randomUUID(), ...data }),

View file

@ -0,0 +1,3 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore('guides_theme');

View file

@ -0,0 +1,4 @@
export const APP_VERSION = '0.1.0';
export const BUILD_TIME: string =
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';

View file

@ -5,8 +5,7 @@
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 { guidesStore as dbStore } from '$lib/data/local-store.js';
import { BookOpen, StackSimple, ClockCounterClockwise, Plus } from '@manacore/shared-icons';
let { children } = $props();
@ -27,9 +26,9 @@
href === '/' ? currentPath === '/' : currentPath.startsWith(href);
onMount(async () => {
await localStore.initialize();
await dbStore.initialize();
if (authStore.isLoggedIn) {
localStore.startSync(() => authStore.getAccessToken());
dbStore.startSync(() => authStore.getValidToken());
}
});
</script>

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { page } from '$app/stores';
import { liveQuery } from 'dexie';
import { goto } from '$app/navigation';
import {
collectionCollection,
guideCollection,
runCollection,
type LocalCollection,
type LocalGuide,
type LocalRun,
} from '$lib/data/local-store.js';
import { runsStore } from '$lib/stores/runs.svelte';
let colId = $derived($page.params.id);
let collection = $state<LocalCollection | null>(null);
let guides = $state<LocalGuide[]>([]);
let runs = $state<LocalRun[]>([]);
$effect(() => {
const id = colId;
const sub = liveQuery(async () => {
const [col, allGuides, allRuns] = await Promise.all([
collectionCollection.get(id),
guideCollection.getAll(),
runCollection.getAll(),
]);
return { col, allGuides, allRuns };
}).subscribe(({ col, allGuides, allRuns }) => {
collection = col ?? null;
if (!col) return;
if (col.type === 'path') {
guides = col.guideOrder
.map((gid) => allGuides.find((g) => g.id === gid))
.filter(Boolean) as LocalGuide[];
} else {
guides = allGuides.filter((g) => g.collectionId === col.id);
}
runs = allRuns;
});
return () => sub.unsubscribe();
});
function isGuideCompleted(guideId: string) {
return runs.some((r) => r.guideId === guideId && r.completedAt);
}
function getLastRunDate(guideId: string): string | null {
const guideRuns = runs.filter((r) => r.guideId === guideId && r.completedAt);
if (!guideRuns.length) return null;
const latest = guideRuns.sort((a, b) => b.completedAt!.localeCompare(a.completedAt!))[0];
return new Date(latest.completedAt!).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
let completedCount = $derived(guides.filter((g) => isGuideCompleted(g.id)).length);
let progress = $derived(guides.length > 0 ? Math.round((completedCount / guides.length) * 100) : 0);
const difficultyConfig = {
easy: { label: 'Einfach', color: 'text-green-600' },
medium: { label: 'Mittel', color: 'text-amber-600' },
hard: { label: 'Schwer', color: 'text-red-600' },
};
async function startGuide(guide: LocalGuide) {
// Find or create active run
const activeRun = await runsStore.getActiveRun(guide.id);
if (activeRun) {
goto(`/guide/${guide.id}/run?runId=${activeRun.id}&mode=${activeRun.mode}`);
} else {
const run = await runsStore.startRun(guide.id, 'scroll');
if (run) goto(`/guide/${guide.id}/run?runId=${run.id}&mode=scroll`);
}
}
</script>
{#if !collection}
<div class="flex h-full items-center justify-center">
<p class="text-muted-foreground">Sammlung nicht gefunden.</p>
</div>
{:else}
<div class="mx-auto max-w-2xl p-4 md:p-8">
<a href="/collections" class="mb-6 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
← Sammlungen
</a>
<!-- Header -->
<div class="mb-8 rounded-2xl p-6" style="background-color: {collection.coverColor ?? '#0d9488'}18">
<div class="flex items-start gap-4">
<span class="text-5xl">{collection.coverEmoji ?? (collection.type === 'path' ? '🗺' : '📚')}</span>
<div class="flex-1">
<div class="mb-1 flex items-center gap-2">
<h1 class="text-2xl font-bold text-foreground">{collection.title}</h1>
<span class="rounded-full bg-surface px-2 py-0.5 text-xs text-muted-foreground">
{collection.type === 'path' ? 'Lernpfad' : 'Bibliothek'}
</span>
</div>
{#if collection.description}
<p class="mb-3 text-sm text-muted-foreground">{collection.description}</p>
{/if}
<div class="text-sm text-muted-foreground">{guides.length} Anleitungen</div>
</div>
</div>
{#if collection.type === 'path'}
<div class="mt-5">
<div class="mb-1.5 flex items-center justify-between text-sm">
<span class="text-muted-foreground">{completedCount} von {guides.length} abgeschlossen</span>
<span class="font-semibold text-primary">{progress}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-surface">
<div class="h-full rounded-full bg-primary transition-all" style="width: {progress}%"></div>
</div>
</div>
{/if}
</div>
<!-- Guide list -->
<div class="space-y-3">
{#each guides as guide, i (guide.id)}
{@const completed = isGuideCompleted(guide.id)}
{@const lastRun = getLastRunDate(guide.id)}
<div
class="flex items-center gap-4 rounded-xl border p-4 transition-all
{completed ? 'border-green-200 bg-green-50/50 dark:border-green-900/30 dark:bg-green-950/20' : 'border-border bg-surface'}"
>
<!-- Index / Check -->
{#if collection.type === 'path'}
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold
{completed ? 'bg-green-500 text-white' : 'bg-muted text-muted-foreground'}"
>
{completed ? '✓' : i + 1}
</div>
{:else}
<span class="text-2xl">{guide.coverEmoji ?? '📖'}</span>
{/if}
<!-- Info -->
<div class="flex-1 min-w-0">
<a href="/guide/{guide.id}" class="block truncate font-medium text-foreground hover:text-primary">
{guide.title}
</a>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="{difficultyConfig[guide.difficulty].color}">{difficultyConfig[guide.difficulty].label}</span>
{#if guide.estimatedMinutes}
<span>· {guide.estimatedMinutes}min</span>
{/if}
{#if lastRun}
<span>· ✓ {lastRun}</span>
{/if}
</div>
</div>
<!-- Action -->
<button
onclick={() => startGuide(guide)}
class="flex-shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors
{completed
? 'border border-border text-muted-foreground hover:bg-accent'
: 'bg-primary text-white hover:bg-primary-hover'}"
>
{completed ? 'Wiederholen' : 'Starten'}
</button>
</div>
{/each}
</div>
{#if guides.length === 0}
<div class="py-16 text-center">
<p class="text-muted-foreground">Diese Sammlung enthält noch keine Anleitungen.</p>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let success = $state(false);
let loading = $state(false);
async function handleResetPassword(email: string) {
loading = true;
error = '';
success = false;
const result = await authStore.resetPassword(email);
if (result.success) success = true;
else error = result.error || 'Passwort-Zurücksetzung fehlgeschlagen';
loading = false;
}
</script>
<ForgotPasswordPage
appName="Guides"
appLogo=""
{loading}
{error}
{success}
onSubmit={handleResetPassword}
loginHref="/login"
/>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION, BUILD_TIME } from '$lib/version';
import '$lib/i18n';
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
if (queryRedirect) return queryRedirect;
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
const translations = $derived(getLoginTranslations($locale || 'de'));
</script>
<svelte:head>
<title>{translations.title} | Guides</title>
</svelte:head>
<LoginPage
appName="Guides"
primaryColor="#0d9488"
onSignIn={(email, password) => authStore.signIn(email, password)}
onResendVerification={(email) => authStore.resendVerificationEmail(email)}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
{goto}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f0fdfa"
darkBackground="#042f2e"
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
buildTime={BUILD_TIME}
/>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const redirectTo = $derived.by(() => {
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
const translations = $derived(getRegisterTranslations($locale || 'de'));
</script>
<svelte:head>
<title>{translations.title} | Guides</title>
</svelte:head>
<RegisterPage
appName="Guides"
primaryColor="#0d9488"
onSignUp={(email, password) => authStore.signUp(email, password)}
onResendVerification={(email) => authStore.resendVerificationEmail(email)}
{goto}
successRedirect={redirectTo}
loginPath="/login"
lightBackground="#f0fdfa"
darkBackground="#042f2e"
{translations}
/>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let loading = $state(false);
let hasToken = $state(false);
let token = $state<string | null>(null);
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let success = $state(false);
onMount(() => {
token = $page.url.searchParams.get('token');
hasToken = !!token;
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (!token) { error = 'Ungültiger Link'; return; }
if (password !== confirmPassword) { error = 'Passwörter stimmen nicht überein'; return; }
if (password.length < 8) { error = 'Mindestens 8 Zeichen'; return; }
loading = true;
try {
const result = await authStore.resetPasswordWithToken(token, password);
if (!result.success) error = result.error || 'Fehler beim Zurücksetzen';
else { success = true; setTimeout(() => goto('/login'), 3000); }
} catch (err) {
error = err instanceof Error ? err.message : 'Unbekannter Fehler';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Passwort zurücksetzen | Guides</title>
</svelte:head>
<div class="flex min-h-screen flex-col bg-gradient-to-b from-teal-50 to-white dark:from-teal-950 dark:to-neutral-900">
<header class="p-4">
<a href="/" class="text-xl font-semibold text-teal-600">📖 Guides</a>
</header>
<main class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md">
<h1 class="mb-8 text-center text-2xl font-bold text-foreground">Passwort zurücksetzen</h1>
{#if success}
<div class="rounded-2xl bg-surface p-8 text-center shadow-lg">
<div class="mb-4 text-5xl"></div>
<p class="mb-6 text-sm text-muted-foreground">Passwort erfolgreich geändert. Du wirst weitergeleitet...</p>
<a href="/login" class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white">Zum Login</a>
</div>
{:else if hasToken}
<div class="rounded-2xl bg-surface p-8 shadow-lg">
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</div>
{/if}
<div>
<label class="mb-1 block text-sm font-medium text-foreground">Neues Passwort</label>
<input type="password" bind:value={password} required minlength={8} autocomplete="new-password"
class="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" />
</div>
<div>
<label class="mb-1 block text-sm font-medium text-foreground">Passwort bestätigen</label>
<input type="password" bind:value={confirmPassword} required minlength={8} autocomplete="new-password"
class="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" />
</div>
<button type="submit" disabled={loading}
class="w-full rounded-xl bg-primary py-3 text-sm font-semibold text-white hover:bg-primary-hover disabled:opacity-50">
{loading ? 'Wird gespeichert...' : 'Passwort ändern'}
</button>
</form>
</div>
{:else}
<div class="rounded-2xl bg-surface p-8 text-center shadow-lg">
<div class="mb-4 text-5xl">⚠️</div>
<p class="mb-6 text-sm text-muted-foreground">Dieser Link ist ungültig oder abgelaufen.</p>
<a href="/forgot-password" class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white">
Neuen Link anfordern
</a>
</div>
{/if}
</div>
</main>
</div>

View file

@ -3,7 +3,7 @@
import '$lib/i18n';
import { onMount } from 'svelte';
import { isLoading as i18nLoading } from 'svelte-i18n';
import { theme } from '@manacore/shared-theme';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();