feat(guides): PillNavigation + public shared guide route

- Replace custom sidebar/bottom-nav with PillNavigation (matches ecosystem pattern)
- Full theme/language/app-switcher/user-dropdown support via PillNavigation
- GuestWelcomeModal + SessionExpiredBanner wired up
- GET /shared/[token] public route: checklist, progress bar, step types, CTA
- Separate layout for /shared so it bypasses AuthGate
- Port corrected to 3027

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 22:00:31 +02:00
parent 8a7efdd654
commit 71db334028
3 changed files with 374 additions and 77 deletions

View file

@ -1,12 +1,27 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { onMount, setContext } from 'svelte';
import { AuthGate } from '@manacore/shared-auth-ui';
import { PillNavigation } from '@manacore/shared-ui';
import { SyncIndicator } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { guidesStore } from '$lib/stores/guides.svelte';
import { guidesStore as dbStore } from '$lib/data/local-store.js';
import { BookOpen, StackSimple, ClockCounterClockwise, Plus } from '@manacore/shared-icons';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
filterHiddenNavItems,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems, getManaApp } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { AuthGate, GuestWelcomeModal, SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
let { children } = $props();
@ -16,102 +31,168 @@
setContext('openCreateGuide', () => { showCreateModal = true; });
setContext('openImportGuide', () => { showImportModal = true; });
// App switcher
let appItems = $derived(getPillAppItems('guides', undefined, undefined, authStore.user?.tier));
// Collapsed state
let isCollapsed = $state(false);
// Theme
let isDark = $derived(theme.isDark);
let pinnedThemes = $derived<ThemeVariant[]>(
[].filter((t): t is ThemeVariant => EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant))
);
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
let themeVariantItems = $derived<PillDropdownItem[]>([
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
// Language
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as Parameters<typeof setLocale>[0]);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Nav items
const navItems = [
{ href: '/', icon: BookOpen, label: 'Bibliothek' },
{ href: '/collections', icon: StackSimple, label: 'Sammlungen' },
{ href: '/history', icon: ClockCounterClockwise, label: 'Verlauf' },
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Bibliothek', icon: 'book' },
{ href: '/collections', label: 'Sammlungen', icon: 'stack' },
{ href: '/history', label: 'Verlauf', icon: 'clock-counter-clockwise' },
];
let currentPath = $derived($page.url.pathname);
let isActive = (href: string) =>
href === '/' ? currentPath === '/' : currentPath.startsWith(href);
const navItems = $derived(filterHiddenNavItems('guides', baseNavItems, {}));
onMount(async () => {
// Guest welcome
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('guides')) {
showGuestWelcome = true;
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
}
function handleToggleTheme() { theme.toggleMode(); }
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') { theme.setMode(mode); }
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
async function handleAuthReady() {
await dbStore.initialize();
if (authStore.isLoggedIn) {
if (authStore.isAuthenticated) {
dbStore.startSync(() => authStore.getValidToken());
}
});
const savedCollapsed = localStorage.getItem('guides-nav-collapsed');
if (savedCollapsed === 'true') isCollapsed = true;
initGuestWelcome();
}
</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>
<svelte:window onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key);
if (num >= 1 && num <= baseNavItems.length) {
e.preventDefault();
goto(baseNavItems[num - 1].href);
}
}
}} />
<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>
<AuthGate
{authStore}
{goto}
allowGuest={true}
onReady={handleAuthReady}
requiredTier={getManaApp('guides')?.requiredTier}
appName={getManaApp('guides')?.name}
>
<div class="flex flex-col min-h-screen">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Guides"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#0d9488"
showAppSwitcher={true}
{appItems}
{userEmail}
allAppsHref="/apps"
/>
<div class="p-3 flex flex-col gap-2">
<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>
<button
onclick={() => (showImportModal = true)}
class="flex w-full items-center justify-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
↓ Importieren
</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 class="relative z-0 pb-24" style="padding-top: 0">
{@render children()}
</main>
<SyncIndicator />
</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) -->
<!-- FAB -->
<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"
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"
aria-label="Neue Anleitung erstellen"
>
<Plus class="h-6 w-6" />
<svg width="24" height="24" viewBox="0 0 256 256" fill="currentColor"><path d="M228 128a12 12 0 0 1-12 12h-76v76a12 12 0 0 1-24 0v-76H40a12 12 0 0 1 0-24h76V40a12 12 0 0 1 24 0v76h76a12 12 0 0 1 12 12Z"/></svg>
</button>
<!-- Guest Welcome -->
<GuestWelcomeModal
appId="guides"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={currentLocale === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
{#if showCreateModal}
<!-- GuideEditModal dynamically imported to keep bundle small -->
{#await import('$lib/components/GuideEditModal.svelte') then { default: GuideEditModal }}
<GuideEditModal
open={true}

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,211 @@
<script lang="ts">
import { page } from '$app/stores';
const SERVER_URL = import.meta.env.PUBLIC_GUIDES_SERVER_URL || 'http://localhost:3027';
interface SharedStep {
id: string;
title: string;
content?: string;
type?: string;
checkable?: boolean;
}
interface SharedGuide {
title: string;
description?: string;
category?: string;
difficulty?: string;
estimatedMinutes?: number;
tags?: string[];
coverEmoji?: string;
coverColor?: string;
}
let token = $derived($page.params.token);
type State = 'loading' | 'loaded' | 'error' | 'expired';
let state = $state<State>('loading');
let guide = $state<SharedGuide | null>(null);
let steps = $state<SharedStep[]>([]);
let errorMsg = $state('');
// Checklist state (local only — shared guides are read-only)
let checked = $state<Record<string, boolean>>({});
$effect(() => {
const t = token;
state = 'loading';
fetch(`${SERVER_URL}/api/v1/share/${t}`)
.then(async (res) => {
if (res.status === 410) { state = 'expired'; return; }
if (!res.ok) { state = 'error'; errorMsg = 'Guide nicht gefunden'; return; }
const data = await res.json<{ guide: SharedGuide; sections: SharedStep[] | { steps: SharedStep[] }[] }>();
guide = data.guide as SharedGuide;
// flatten sections → steps
const raw = data.sections as unknown[];
if (raw.length > 0 && 'steps' in (raw[0] as object)) {
steps = (raw as { steps: SharedStep[] }[]).flatMap((s) => s.steps ?? []);
} else {
steps = raw as SharedStep[];
}
state = 'loaded';
})
.catch(() => { state = 'error'; errorMsg = 'Server nicht erreichbar'; });
});
const difficultyLabel: Record<string, string> = { easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' };
const progress = $derived(
steps.filter((s) => s.checkable).length > 0
? Math.round(
(steps.filter((s) => s.checkable && checked[s.id]).length /
steps.filter((s) => s.checkable).length) *
100
)
: 0
);
const stepTypeConfig: Record<string, { icon: string; border: string; bg: string }> = {
instruction: { icon: '→', border: 'border-l-teal-500', bg: '' },
warning: { icon: '⚠', border: 'border-l-orange-400', bg: 'bg-orange-50/50 dark:bg-orange-950/20' },
tip: { icon: '💡', border: 'border-l-violet-400', bg: 'bg-violet-50/50 dark:bg-violet-950/20' },
checkpoint: { icon: '✓', border: 'border-l-blue-400', bg: 'bg-blue-50/50 dark:bg-blue-950/20' },
code: { icon: '</>', border: 'border-l-slate-400', bg: 'bg-slate-50/50 dark:bg-slate-950/20 font-mono text-xs' },
};
</script>
<svelte:head>
{#if guide}
<title>{guide.title} — Mana Guides</title>
<meta name="description" content={guide.description ?? ''} />
{:else}
<title>Geteilte Anleitung — Mana Guides</title>
{/if}
</svelte:head>
<div class="min-h-screen bg-neutral-50 dark:bg-neutral-950">
<div class="mx-auto max-w-2xl px-4 py-8">
{#if state === 'loading'}
<div class="flex items-center justify-center py-24">
<svg class="animate-spin h-8 w-8 text-teal-600" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
{:else if state === 'expired'}
<div class="flex flex-col items-center justify-center py-24 text-center">
<span class="mb-4 text-5xl"></span>
<h1 class="text-xl font-bold text-neutral-900 dark:text-white mb-2">Link abgelaufen</h1>
<p class="text-sm text-neutral-500">Dieser Freigabe-Link ist nicht mehr gültig (nach 7 Tagen abgelaufen).</p>
<a href="/" class="mt-6 text-sm text-teal-600 hover:underline">Zur Guides-App →</a>
</div>
{:else if state === 'error'}
<div class="flex flex-col items-center justify-center py-24 text-center">
<span class="mb-4 text-5xl"></span>
<h1 class="text-xl font-bold text-neutral-900 dark:text-white mb-2">{errorMsg}</h1>
<a href="/" class="mt-6 text-sm text-teal-600 hover:underline">Zur Guides-App →</a>
</div>
{:else if guide}
<!-- Branding strip -->
<div class="mb-6 flex items-center gap-2">
<span class="text-xl">📖</span>
<span class="text-sm font-semibold text-teal-600">Mana Guides</span>
<span class="ml-auto text-xs text-neutral-400">Geteilte Anleitung</span>
</div>
<!-- Cover -->
<div class="mb-6 rounded-2xl p-5" style="background-color: {guide.coverColor ?? '#0d9488'}18">
<div class="flex items-start gap-4">
<span class="text-5xl">{guide.coverEmoji ?? '📖'}</span>
<div>
<h1 class="text-xl font-bold text-neutral-900 dark:text-white">{guide.title}</h1>
{#if guide.description}
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">{guide.description}</p>
{/if}
<div class="mt-2 flex flex-wrap gap-2">
{#if guide.difficulty}
<span class="text-xs bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 px-2 py-0.5 rounded-full">
{difficultyLabel[guide.difficulty] ?? guide.difficulty}
</span>
{/if}
{#if guide.estimatedMinutes}
<span class="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full">
{guide.estimatedMinutes} Min.
</span>
{/if}
<span class="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full">
{steps.length} Schritte
</span>
</div>
</div>
</div>
</div>
<!-- Progress (if any checkable steps) -->
{#if steps.some((s) => s.checkable)}
<div class="mb-6 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Fortschritt</span>
<span class="text-sm text-teal-600 font-semibold">{progress}%</span>
</div>
<div class="h-2 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div class="h-full bg-teal-500 rounded-full transition-all" style="width: {progress}%"></div>
</div>
</div>
{/if}
<!-- Steps -->
<div class="space-y-3">
{#each steps as step, i}
{@const cfg = stepTypeConfig[step.type ?? 'instruction'] ?? stepTypeConfig.instruction}
<div
class="rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 border-l-4 {cfg.border} {cfg.bg} overflow-hidden"
>
<div class="p-4 flex gap-3 items-start">
{#if step.checkable}
<button
onclick={() => { checked[step.id] = !checked[step.id]; }}
class="shrink-0 mt-0.5 w-5 h-5 rounded border-2 transition-colors flex items-center justify-center
{checked[step.id]
? 'bg-teal-600 border-teal-600 text-white'
: 'border-neutral-300 dark:border-neutral-600 hover:border-teal-400'}"
aria-label="Schritt abhaken"
>
{#if checked[step.id]}
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor"><path d="m229.66 77.66-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69 218.34 66.34a8 8 0 0 1 11.32 11.32Z"/></svg>
{/if}
</button>
{:else}
<span class="shrink-0 mt-0.5 text-sm text-neutral-400">{cfg.icon}</span>
{/if}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-neutral-900 dark:text-white {checked[step.id] ? 'line-through text-neutral-400' : ''}">
{i + 1}. {step.title}
</p>
{#if step.content}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400 whitespace-pre-wrap">{step.content}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- CTA -->
<div class="mt-10 rounded-2xl bg-teal-50 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800 p-5 text-center">
<p class="text-sm font-semibold text-teal-800 dark:text-teal-200 mb-1">Eigene Anleitungen erstellen?</p>
<p class="text-xs text-teal-600 dark:text-teal-400 mb-4">Mit Mana Guides kannst du SOPs, Rezepte, Tutorials und Lernpfade erstellen — kostenlos.</p>
<a
href="/"
class="inline-block rounded-xl bg-teal-600 text-white text-sm font-semibold px-5 py-2.5 hover:bg-teal-700 transition-colors"
>
Mana Guides ausprobieren →
</a>
</div>
{/if}
</div>
</div>