feat(workbench): unify system pages as workbench apps + categorize picker

Add 8 system pages as first-class workbench apps (Settings, Themes,
Profile, Admin, API Keys, Help, Feedback, Subscription) so they can be
opened as side-by-side panels next to other apps instead of requiring
a full-page route switch. Existing routes remain as fullscreen
fallback/deep-link targets.

Group the AppPagePicker by 5 categories (Companion, Leben, Arbeit,
Kreativ, System) with collapsible sections; System is collapsed by
default. Search still works as a flat fuzzy match across all apps.
Category assignment lives in a central map so registerApp() calls stay
unchanged — unmapped apps fall back to System, which surfaces
miscategorization at a glance.

Remove profile-data and theme-picker duplication from Settings (both
are separate workbench apps now): Settings defaults to 'Allgemein' and
passes showTheme={false} to GlobalSettingsSection; SettingsSidebar
accepts a categories override so the workbench version hides Profile.

Fix Cannot-read-'subscribe'-of-undefined crash in mood/sleep/body/
stretch ListViews when opened in the workbench: replace getContext
(which is only set by the route +layout.svelte) with direct query-hook
calls, matching the goals/companion pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 13:48:44 +02:00
parent 51c8a52811
commit 9ff2cfcdac
16 changed files with 2819 additions and 341 deletions

View file

@ -57,6 +57,14 @@ import {
Robot,
Target,
Smiley,
Gear,
Palette,
UserCircle,
ShieldCheck,
Key,
Question,
ChatCircleDots,
CreditCard,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -977,3 +985,85 @@ registerApp({
list: { load: () => import('$lib/modules/goals/ListView.svelte') },
},
});
// ── System Pages ─────────────────────────────────────
registerApp({
id: 'settings',
name: 'Einstellungen',
color: '#6B7280',
icon: Gear,
views: {
list: { load: () => import('$lib/modules/settings/ListView.svelte') },
},
});
registerApp({
id: 'themes',
name: 'Themes',
color: '#EC4899',
icon: Palette,
views: {
list: { load: () => import('$lib/modules/themes/ListView.svelte') },
},
});
registerApp({
id: 'profile',
name: 'Profil',
color: '#6366F1',
icon: UserCircle,
views: {
list: { load: () => import('$lib/modules/profile/ListView.svelte') },
},
});
registerApp({
id: 'admin',
name: 'Admin',
color: '#EF4444',
icon: ShieldCheck,
views: {
list: { load: () => import('$lib/modules/admin/ListView.svelte') },
},
});
registerApp({
id: 'api-keys',
name: 'API Keys',
color: '#F59E0B',
icon: Key,
views: {
list: { load: () => import('$lib/modules/api-keys/ListView.svelte') },
},
});
registerApp({
id: 'help',
name: 'Hilfe',
color: '#3B82F6',
icon: Question,
views: {
list: { load: () => import('$lib/modules/help/ListView.svelte') },
},
});
registerApp({
id: 'feedback',
name: 'Feedback',
color: '#8B5CF6',
icon: ChatCircleDots,
views: {
list: { load: () => import('$lib/modules/feedback/ListView.svelte') },
},
});
registerApp({
id: 'subscription',
name: 'Abonnement',
color: '#10B981',
icon: CreditCard,
views: {
list: { load: () => import('$lib/modules/subscription/ListView.svelte') },
},
});

View file

@ -0,0 +1,115 @@
/**
* App Categories Groups apps in the workbench AppPagePicker so users
* can find pages by intent rather than scanning an alphabetical list.
*
* Five categories (Vorschlag C):
* - companion: Companion Brain pages (myday, eventstream, companion, goals)
* - life: Personal / wellness / everyday-life tracking
* - work: Productivity & planning
* - creative: Creative, learning, generation
* - system: Settings, admin, help, billing everything meta
*
* Category assignment lives in APP_CATEGORY_MAP (keyed by appId) so
* registerApp() calls stay unchanged. Anything not in the map falls
* back to 'system'.
*/
import type { Component } from 'svelte';
import { Robot, Heart, Briefcase, Sparkle, Gear } from '@mana/shared-icons';
export type AppCategory = 'companion' | 'life' | 'work' | 'creative' | 'system';
export interface CategoryMeta {
id: AppCategory;
label: string;
icon: Component;
order: number;
}
export const APP_CATEGORIES: CategoryMeta[] = [
{ id: 'companion', label: 'Companion', icon: Robot, order: 0 },
{ id: 'life', label: 'Leben', icon: Heart, order: 1 },
{ id: 'work', label: 'Arbeit', icon: Briefcase, order: 2 },
{ id: 'creative', label: 'Kreativ', icon: Sparkle, order: 3 },
{ id: 'system', label: 'System', icon: Gear, order: 4 },
];
/**
* appId AppCategory. Apps not listed here default to 'system'.
*/
export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
// Companion Brain
myday: 'companion',
eventstream: 'companion',
companion: 'companion',
goals: 'companion',
// Leben — personal, wellness, everyday
habits: 'life',
body: 'life',
sleep: 'life',
mood: 'life',
stretch: 'life',
cycles: 'life',
dreams: 'life',
drink: 'life',
meditate: 'life',
journal: 'life',
nutriphi: 'life',
recipes: 'life',
plants: 'life',
finance: 'life',
contacts: 'life',
places: 'life',
citycorners: 'life',
news: 'life',
inventory: 'life',
storage: 'life',
who: 'life',
firsts: 'life',
memoro: 'life',
questions: 'life',
// Arbeit — productivity, planning, communication
todo: 'work',
calendar: 'work',
notes: 'work',
times: 'work',
events: 'work',
mail: 'work',
chat: 'work',
context: 'work',
automations: 'work',
calc: 'work',
// Kreativ — generation, learning, media
music: 'creative',
picture: 'creative',
photos: 'creative',
presi: 'creative',
moodlit: 'creative',
cards: 'creative',
skilltree: 'creative',
guides: 'creative',
zitare: 'creative',
uload: 'creative',
playground: 'creative',
// System — settings, admin, meta
settings: 'system',
themes: 'system',
profile: 'system',
admin: 'system',
'api-keys': 'system',
help: 'system',
feedback: 'system',
subscription: 'system',
};
export function getAppCategory(appId: string): AppCategory {
return APP_CATEGORY_MAP[appId] ?? 'system';
}
export function getCategoryMeta(id: AppCategory): CategoryMeta {
return APP_CATEGORIES.find((c) => c.id === id) ?? APP_CATEGORIES[APP_CATEGORIES.length - 1];
}

View file

@ -5,15 +5,23 @@
-->
<script lang="ts">
import { MagnifyingGlass, X } from '@mana/shared-icons';
import { categories, searchSettings, type CategoryId, type SearchEntry } from './searchIndex';
import {
categories as defaultCategories,
searchSettings,
type Category,
type CategoryId,
type SearchEntry,
} from './searchIndex';
interface Props {
activeCategory: CategoryId;
onSelect: (id: CategoryId) => void;
onJump: (entry: SearchEntry) => void;
/** Override the default categories list (e.g. to exclude profile in workbench). */
categories?: Category[];
}
let { activeCategory, onSelect, onJump }: Props = $props();
let { activeCategory, onSelect, onJump, categories = defaultCategories }: Props = $props();
let query = $state('');
let results = $derived(searchSettings(query));

View file

@ -1,12 +1,17 @@
<!--
AppPagePicker — Shows available apps to add as pages to the workbench.
AppPagePicker — Shows available apps to add as pages to the workbench,
grouped by category (Companion, Leben, Arbeit, Kreativ, System).
When a search query is active the categories collapse into a flat
best-match list.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { tick } from 'svelte';
import { MagnifyingGlass } from '@mana/shared-icons';
import { MagnifyingGlass, CaretDown, CaretRight } from '@mana/shared-icons';
import PickerOverlay from '$lib/components/PickerOverlay.svelte';
import { getAccessibleApps } from '$lib/app-registry';
import type { AppDescriptor } from '$lib/app-registry/types';
import { APP_CATEGORIES, getAppCategory, type AppCategory } from '$lib/app-registry/categories';
function appName(id: string, fallback: string): string {
const key = `apps.${id}`;
@ -28,68 +33,156 @@
let query = $state('');
let searchInput = $state<HTMLInputElement | null>(null);
// Filter twice: tier-gate first (so guests + public users don't see
// founder/alpha/beta apps at all), then drop apps that are already
// open in the current scene. Sort alphabetically by the displayed
// (i18n-resolved) name, then apply the search query.
let availableApps = $derived(
// Collapsed state per category — persist across openings in-session.
let collapsed = $state<Record<AppCategory, boolean>>({
companion: false,
life: false,
work: false,
creative: false,
system: true, // System is collapsed by default — noisy and rarely toggled
});
// Tier-gate first, then drop open apps, then attach display name.
let available = $derived(
getAccessibleApps(userTier)
.filter((app) => !activeAppIds.includes(app.id))
.map((app) => ({ app, displayName: appName(app.id, app.name) }))
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'de'))
.filter(({ displayName }) =>
query.trim() === '' ? true : displayName.toLowerCase().includes(query.trim().toLowerCase())
)
.map(({ app }) => app)
);
// Auto-focus the search input when the picker opens.
// Search mode: filter flat across all apps when query is non-empty.
let searchMode = $derived(query.trim().length > 0);
let searchResults = $derived(
searchMode
? available.filter(({ displayName }) =>
displayName.toLowerCase().includes(query.trim().toLowerCase())
)
: []
);
// Grouped mode: partition into the 5 categories, preserving order.
let grouped = $derived(
searchMode
? []
: APP_CATEGORIES.map((cat) => ({
category: cat,
apps: available.filter(({ app }) => getAppCategory(app.id) === cat.id),
})).filter((g) => g.apps.length > 0)
);
$effect(() => {
tick().then(() => searchInput?.focus());
});
function toggleCategory(id: AppCategory) {
collapsed[id] = !collapsed[id];
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && availableApps.length > 0) {
if (e.key === 'Enter' && searchResults.length > 0) {
e.preventDefault();
onSelect(availableApps[0].id);
onSelect(searchResults[0].app.id);
}
}
// PickerOverlay expects a flat items array; we bypass that and render
// groups ourselves in a custom snippet via the `item` callback — but
// we feed it an empty items array and use the `footer` slot for our
// entire custom layout. Cleaner: render our own layout inside a
// single-entry item snippet.
type Row =
| { kind: 'header'; category: (typeof APP_CATEGORIES)[number]; count: number }
| { kind: 'app'; app: AppDescriptor; displayName: string; category: AppCategory };
let rows = $derived<Row[]>(
searchMode
? searchResults.map(({ app, displayName }) => ({
kind: 'app' as const,
app,
displayName,
category: getAppCategory(app.id),
}))
: grouped.flatMap((g) => {
const header: Row = {
kind: 'header' as const,
category: g.category,
count: g.apps.length,
};
if (collapsed[g.category.id]) return [header];
const apps: Row[] = g.apps.map(({ app, displayName }) => ({
kind: 'app' as const,
app,
displayName,
category: g.category.id,
}));
return [header, ...apps];
})
);
</script>
<PickerOverlay
title="App hinzufügen"
items={availableApps}
{onClose}
width="300px"
emptyLabel={query.trim() === '' ? 'Alle Apps sind bereits geöffnet' : 'Keine Treffer'}
>
{#snippet subheader()}
<div class="search-wrap">
<MagnifyingGlass size={14} />
<input
bind:this={searchInput}
bind:value={query}
type="text"
placeholder="Suchen…"
class="search-input"
onkeydown={handleSearchKeydown}
/>
</div>
{/snippet}
{#snippet item(app)}
{@const Icon = app.icon}
<button class="picker-option" onclick={() => onSelect(app.id)}>
<div class="app-icon-wrap">
{#if Icon}
<Icon size={18} />
{/if}
<div class="app-picker-wrapper">
<PickerOverlay
title="App hinzufügen"
items={rows}
{onClose}
width="320px"
emptyLabel={searchMode ? 'Keine Treffer' : 'Alle Apps sind bereits geöffnet'}
>
{#snippet subheader()}
<div class="search-wrap">
<MagnifyingGlass size={14} />
<input
bind:this={searchInput}
bind:value={query}
type="text"
placeholder="Suchen…"
class="search-input"
onkeydown={handleSearchKeydown}
/>
</div>
<span class="app-name">{appName(app.id, app.name)}</span>
</button>
{/snippet}
</PickerOverlay>
{/snippet}
{#snippet item(row)}
{#if row.kind === 'header'}
{@const CatIcon = row.category.icon}
{@const isCollapsed = collapsed[row.category.id]}
<button class="cat-header" onclick={() => toggleCategory(row.category.id)}>
<span class="cat-chevron">
{#if isCollapsed}
<CaretRight size={12} weight="bold" />
{:else}
<CaretDown size={12} weight="bold" />
{/if}
</span>
<span class="cat-icon-wrap">
<CatIcon size={14} />
</span>
<span class="cat-label">{row.category.label}</span>
<span class="cat-count">{row.count}</span>
</button>
{:else}
{@const Icon = row.app.icon}
<button class="app-option" onclick={() => onSelect(row.app.id)}>
<span class="app-icon-wrap">
{#if Icon}<Icon size={16} />{/if}
</span>
<span class="app-name">{row.displayName}</span>
</button>
{/if}
{/snippet}
</PickerOverlay>
</div>
<style>
.app-picker-wrapper {
display: contents;
}
/* Hide auto-rendered dividers from PickerOverlay — we use typography
and collapse chevrons for visual grouping instead. */
.app-picker-wrapper :global(.picker .divider) {
display: none;
}
.search-wrap {
display: flex;
align-items: center;
@ -116,6 +209,73 @@
color: hsl(var(--color-muted-foreground));
}
/* Category header row */
:global(.picker .cat-header) {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.625rem 0.5rem 0.375rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.375rem;
text-align: left;
color: hsl(var(--color-muted-foreground));
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.15s;
}
:global(.picker .cat-header:hover) {
color: hsl(var(--color-foreground));
}
:global(.picker .cat-chevron) {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
}
:global(.picker .cat-icon-wrap) {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
flex-shrink: 0;
}
:global(.picker .cat-label) {
flex: 1;
}
:global(.picker .cat-count) {
font-size: 0.625rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.4);
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
text-transform: none;
letter-spacing: 0;
}
/* App row */
:global(.picker .app-option) {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.375rem;
transition: background 0.15s;
text-align: left;
}
:global(.picker .app-option:hover) {
background: hsl(var(--color-surface-hover));
}
:global(.picker .app-icon-wrap) {
display: flex;
align-items: center;
@ -125,7 +285,7 @@
flex-shrink: 0;
color: hsl(var(--color-muted-foreground));
}
:global(.picker .picker-option:hover .app-icon-wrap) {
:global(.picker .app-option:hover .app-icon-wrap) {
color: hsl(var(--color-foreground));
}
:global(.picker .app-name) {

View file

@ -0,0 +1,264 @@
<!--
Admin — Workbench-embedded admin dashboard with stats, security overview,
and quick links to monitoring tools.
-->
<script lang="ts">
import { onMount } from 'svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
import { adminService, type AdminStats } from '$lib/api/services/admin';
let stats = $state<AdminStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
const quickLinks = [
{
name: 'Grafana Dashboard',
url: 'https://grafana.mana.how',
description: 'System & Backend Metrics',
icon: 'grafana' as const,
},
{
name: 'Umami Analytics',
url: 'https://stats.mana.how',
description: 'Web Analytics',
icon: 'analytics' as const,
},
{
name: 'Docker Dashboard',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container Metrics',
icon: 'docker' as const,
},
{
name: 'System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk',
icon: 'grafana' as const,
},
];
onMount(async () => {
const result = await adminService.getStats();
if (result.error) {
error = result.error;
} else {
stats = result.data;
}
loading = false;
});
let userGrowthPercent = $derived(
stats
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
: 0
);
</script>
<div class="admin-page">
<!-- Stats Grid -->
<div class="stats-grid">
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
<StatCard
title="New Users (7d)"
value={stats?.newUsers7d ?? '-'}
change={userGrowthPercent}
changeLabel="vs previous"
icon="users"
{loading}
/>
<StatCard
title="Active Sessions"
value={stats?.activeSessions ?? '-'}
icon="activity"
{loading}
/>
<StatCard
title="Unique Users (24h)"
value={stats?.uniqueUsers24h ?? '-'}
icon="clock"
{loading}
/>
</div>
<!-- Security & Quick Links -->
<div class="panels">
<!-- Security Overview -->
<div class="panel">
<h3 class="panel-title">Security (Last 7 Days)</h3>
{#if loading}
<div class="loading-rows">
<div class="loading-bar"></div>
<div class="loading-bar short"></div>
</div>
{:else if stats}
<div class="security-rows">
<div class="security-row">
<div class="security-label">
<span class="dot green"></span>
<span>Successful Logins</span>
</div>
<span class="security-value">{stats.loginSuccess7d}</span>
</div>
<div class="security-row">
<div class="security-label">
<span class="dot red"></span>
<span>Failed Logins</span>
</div>
<span class="security-value">{stats.loginFailed7d}</span>
</div>
<div class="security-divider"></div>
<div class="security-row">
<span class="security-muted">Success Rate</span>
<span class="security-rate">
{stats.loginSuccess7d + stats.loginFailed7d > 0
? Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)
: '—'}%
</span>
</div>
</div>
{/if}
</div>
<QuickLinks links={quickLinks} />
</div>
{#if error}
<div class="error-box">
<p>{error}</p>
</div>
{/if}
</div>
<style>
.admin-page {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
overflow-y: auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
}
.panels {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.panel {
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.panel-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.75rem;
}
.loading-rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.loading-bar {
height: 0.875rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.25rem;
animation: pulse 1.5s ease-in-out infinite;
}
.loading-bar.short {
width: 75%;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.security-rows {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.security-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.security-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.dot.green {
background: hsl(142 71% 45%);
}
.dot.red {
background: hsl(0 84% 60%);
}
.security-value {
font-family: monospace;
font-size: 0.8125rem;
}
.security-divider {
border-top: 1px solid hsl(var(--color-border));
padding-top: 0.375rem;
}
.security-muted {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.security-rate {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(142 71% 45%);
}
.error-box {
padding: 0.75rem;
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.5rem;
background: hsl(0 84% 60% / 0.08);
}
.error-box p {
font-size: 0.8125rem;
color: hsl(0 84% 60%);
}
</style>

View file

@ -0,0 +1,685 @@
<!--
API Keys — Workbench-embedded API key management with create/revoke
and usage instructions for STT/TTS services.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { Button, Input, Card, Badge } from '@mana/shared-ui';
import { Check, Copy, Info, Key, Plus, Prohibit } from '@mana/shared-icons';
import { apiKeysService, type ApiKey, type ApiKeyWithSecret } from '$lib/api/api-keys';
let loading = $state(true);
let apiKeys = $state<ApiKey[]>([]);
let error = $state<string | null>(null);
let showCreateModal = $state(false);
let creating = $state(false);
let newKeyName = $state('');
let newKeyScopes = $state<{ stt: boolean; tts: boolean }>({ stt: true, tts: true });
let newKeyRateLimit = $state('60');
let createdKey = $state<ApiKeyWithSecret | null>(null);
let copied = $state(false);
let revoking = $state<string | null>(null);
let activeKeys = $derived(apiKeys.filter((k) => !k.revokedAt));
let revokedKeys = $derived(apiKeys.filter((k) => k.revokedAt));
onMount(async () => {
await loadKeys();
});
async function loadKeys() {
loading = true;
error = null;
const result = await apiKeysService.list();
if (result.error) {
error = result.error;
} else {
apiKeys = result.data || [];
}
loading = false;
}
async function handleCreate() {
if (!newKeyName.trim()) return;
const scopes: string[] = [];
if (newKeyScopes.stt) scopes.push('stt');
if (newKeyScopes.tts) scopes.push('tts');
if (scopes.length === 0) {
error = 'Please select at least one scope';
return;
}
creating = true;
const result = await apiKeysService.create({
name: newKeyName.trim(),
scopes,
rateLimitRequests: parseInt(newKeyRateLimit, 10) || 60,
});
if (result.error) {
error = result.error;
} else if (result.data) {
createdKey = result.data;
const { key: _omit, ...withoutSecret } = result.data;
apiKeys = [...apiKeys, withoutSecret];
}
creating = false;
newKeyName = '';
}
async function handleRevoke(id: string) {
revoking = id;
const result = await apiKeysService.revoke(id);
if (result.error) {
error = result.error;
} else {
apiKeys = apiKeys.map((k) =>
k.id === id ? { ...k, revokedAt: new Date().toISOString() } : k
);
}
revoking = null;
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function closeCreateModal() {
showCreateModal = false;
createdKey = null;
newKeyName = '';
newKeyScopes = { stt: true, tts: true };
newKeyRateLimit = '60';
copied = false;
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<div class="apikeys-page">
<div class="header">
<button class="add-btn" onclick={() => (showCreateModal = true)}>
<Plus size={14} weight="bold" /> API Key
</button>
</div>
{#if loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
{#if error}
<div class="error-box">{error}</div>
{/if}
<!-- Active Keys -->
<div class="section">
<div class="section-header">
<Key size={16} />
<span class="section-title">Active Keys</span>
<span class="section-count">{activeKeys.length}</span>
</div>
{#if activeKeys.length === 0}
<div class="empty">
<Key size={32} />
<p>No API keys yet</p>
</div>
{:else}
<div class="key-list">
{#each activeKeys as key (key.id)}
<div class="key-card">
<div class="key-info">
<div class="key-name-row">
<span class="key-name">{key.name}</span>
<span class="key-scope">{key.scopes.join(', ')}</span>
<span class="key-rate">{key.rateLimitRequests}/min</span>
</div>
<div class="key-meta">
<code class="key-prefix">{key.keyPrefix}</code>
<span>Created: {formatDate(key.createdAt)}</span>
</div>
</div>
<button
class="revoke-btn"
disabled={revoking === key.id}
onclick={() => handleRevoke(key.id)}
>
{revoking === key.id ? '...' : 'Revoke'}
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Revoked Keys -->
{#if revokedKeys.length > 0}
<div class="section dimmed">
<div class="section-header">
<Prohibit size={16} />
<span class="section-title">Revoked</span>
<span class="section-count">{revokedKeys.length}</span>
</div>
<div class="key-list">
{#each revokedKeys as key (key.id)}
<div class="key-card revoked">
<div class="key-info">
<span class="key-name strikethrough">{key.name}</span>
<div class="key-meta">
<code class="key-prefix">{key.keyPrefix}</code>
<span>Revoked: {formatDate(key.revokedAt)}</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Usage -->
<div class="section">
<div class="section-header">
<Info size={16} />
<span class="section-title">How to Use</span>
</div>
<div class="usage-block">
<p class="usage-label">Speech-to-Text (STT)</p>
<pre class="usage-code"><code
>curl -X POST https://gpu-stt.mana.how/transcribe \
-H "X-API-Key: sk_live_..." \
-F "audio=@audio.mp3"</code
></pre>
</div>
<div class="usage-block">
<p class="usage-label">Text-to-Speech (TTS)</p>
<pre class="usage-code"><code
>curl -X POST https://tts-api.mana.how/synthesize/kokoro \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{{ text: 'Hello', voice: 'af_heart' }}' \
--output speech.wav</code
></pre>
</div>
</div>
{/if}
</div>
<!-- Create API Key Modal -->
{#if showCreateModal}
<div class="modal-backdrop">
<button class="backdrop-btn" onclick={closeCreateModal} aria-label="Close modal"></button>
<div class="modal">
{#if createdKey}
<div class="modal-success">
<div class="success-icon"><Check size={20} /></div>
<h3 class="modal-title">API Key Created</h3>
<p class="modal-hint">Copy your API key now. You won't be able to see it again.</p>
<div class="key-display">
<code>{createdKey.key}</code>
<button class="copy-btn" onclick={() => copyToClipboard(createdKey!.key)}>
{#if copied}<Check size={16} />{:else}<Copy size={16} />{/if}
</button>
</div>
{#if copied}<p class="copied-msg">Copied!</p>{/if}
<button class="done-btn" onclick={closeCreateModal}>Done</button>
</div>
{:else}
<h3 class="modal-title">Create API Key</h3>
<label class="field-label" for="wbKeyName">Key Name</label>
<input
id="wbKeyName"
type="text"
class="field-input"
bind:value={newKeyName}
placeholder="e.g., Production API Key"
/>
<span class="field-label">Scopes</span>
<div class="scope-checks">
<label class="scope-check">
<input type="checkbox" bind:checked={newKeyScopes.stt} /> STT
</label>
<label class="scope-check">
<input type="checkbox" bind:checked={newKeyScopes.tts} /> TTS
</label>
</div>
<label class="field-label" for="wbRateLimit">Rate Limit</label>
<div class="rate-row">
<input
id="wbRateLimit"
type="number"
class="field-input rate-input"
bind:value={newKeyRateLimit}
/>
<span class="rate-unit">req/min</span>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick={closeCreateModal}>Cancel</button>
<button
class="create-btn"
disabled={!newKeyName.trim() || (!newKeyScopes.stt && !newKeyScopes.tts) || creating}
onclick={handleCreate}
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
{/if}
</div>
</div>
{/if}
<style>
.apikeys-page {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
overflow-y: auto;
}
.header {
display: flex;
justify-content: flex-end;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.add-btn:hover {
background: hsl(var(--color-primary) / 0.2);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-box {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: hsl(0 84% 60% / 0.08);
color: hsl(0 84% 60%);
font-size: 0.8125rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section.dimmed {
opacity: 0.6;
}
.section-header {
display: flex;
align-items: center;
gap: 0.375rem;
color: hsl(var(--color-muted-foreground));
}
.section-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.section-count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
opacity: 0.5;
}
.key-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.key-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.key-card.revoked {
opacity: 0.6;
}
.key-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.key-name-row {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.key-name {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.key-name.strikethrough {
text-decoration: line-through;
}
.key-scope {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.key-rate {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
}
.key-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
flex-wrap: wrap;
}
.key-prefix {
font-family: monospace;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: 0.25rem;
}
.revoke-btn {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.375rem;
background: transparent;
color: hsl(0 84% 60%);
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
}
.revoke-btn:hover {
background: hsl(0 84% 60% / 0.08);
}
.revoke-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Usage */
.usage-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.usage-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.usage-code {
padding: 0.5rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: 0.5rem;
font-size: 0.6875rem;
overflow-x: auto;
margin: 0;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.backdrop-btn {
position: absolute;
inset: 0;
background: hsl(0 0% 0% / 0.5);
border: none;
cursor: pointer;
}
.modal {
position: relative;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.2);
max-width: 24rem;
width: calc(100% - 2rem);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.modal-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.success-icon {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 45%);
display: flex;
align-items: center;
justify-content: center;
}
.modal-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.modal-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.key-display {
position: relative;
width: 100%;
padding: 0.75rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: 0.5rem;
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
}
.copy-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem;
border-radius: 0.25rem;
}
.copy-btn:hover {
background: hsl(var(--color-surface-hover));
}
.copied-msg {
font-size: 0.75rem;
color: hsl(142 71% 45%);
}
.done-btn,
.create-btn {
width: 100%;
padding: 0.5rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.done-btn:hover,
.create-btn:hover {
opacity: 0.9;
}
.create-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.field-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.field-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.field-input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.5);
}
.scope-checks {
display: flex;
gap: 1rem;
}
.scope-check {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
}
.rate-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rate-input {
width: 5rem;
}
.rate-unit {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.modal-actions {
display: flex;
gap: 0.5rem;
}
.cancel-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.cancel-btn:hover {
background: hsl(var(--color-surface-hover));
}
</style>

View file

@ -6,20 +6,20 @@
daily energy/sleep/soreness/mood card; recent workouts.
-->
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { Observable } from 'dexie';
import type {
BodyExercise,
BodyRoutine,
BodyWorkout,
BodySet,
BodyMeasurement,
BodyCheck,
BodyPhase,
} from './types';
import type { MealWithNutrition } from '$lib/modules/nutriphi/types';
import { getActiveWorkout, getActivePhase } from './queries';
import {
useAllBodyExercises,
useAllBodyRoutines,
useAllBodyWorkouts,
useAllBodySets,
useAllBodyMeasurements,
useAllBodyChecks,
useAllBodyPhases,
useNutriphiMealsSince,
dateNDaysAgo,
getActiveWorkout,
getActivePhase,
} from './queries';
import { bodyStore } from './stores/body.svelte';
import WorkoutLogger from './components/WorkoutLogger.svelte';
import MeasurementForm from './components/MeasurementForm.svelte';
@ -31,56 +31,23 @@
import ExerciseProgressionChart from './components/ExerciseProgressionChart.svelte';
import CalorieWeightChart from './components/CalorieWeightChart.svelte';
const exercises$: Observable<BodyExercise[]> = getContext('bodyExercises');
const routines$: Observable<BodyRoutine[]> = getContext('bodyRoutines');
const workouts$: Observable<BodyWorkout[]> = getContext('bodyWorkouts');
const sets$: Observable<BodySet[]> = getContext('bodySets');
const measurements$: Observable<BodyMeasurement[]> = getContext('bodyMeasurements');
const checks$: Observable<BodyCheck[]> = getContext('bodyChecks');
const phases$: Observable<BodyPhase[]> = getContext('bodyPhases');
const meals$: Observable<MealWithNutrition[]> = getContext('bodyNutriphiMeals');
const exercisesQuery = useAllBodyExercises();
const routinesQuery = useAllBodyRoutines();
const workoutsQuery = useAllBodyWorkouts();
const setsQuery = useAllBodySets();
const measurementsQuery = useAllBodyMeasurements();
const checksQuery = useAllBodyChecks();
const phasesQuery = useAllBodyPhases();
const mealsQuery = useNutriphiMealsSince(dateNDaysAgo(56));
let exercises = $state<BodyExercise[]>([]);
let routines = $state<BodyRoutine[]>([]);
let workouts = $state<BodyWorkout[]>([]);
let sets = $state<BodySet[]>([]);
let measurements = $state<BodyMeasurement[]>([]);
let checks = $state<BodyCheck[]>([]);
let phases = $state<BodyPhase[]>([]);
let meals = $state<MealWithNutrition[]>([]);
$effect(() => {
const sub = exercises$.subscribe((v) => (exercises = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = routines$.subscribe((v) => (routines = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = workouts$.subscribe((v) => (workouts = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = sets$.subscribe((v) => (sets = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = measurements$.subscribe((v) => (measurements = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = checks$.subscribe((v) => (checks = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = phases$.subscribe((v) => (phases = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = meals$.subscribe((v) => (meals = v));
return () => sub.unsubscribe();
});
let exercises = $derived(exercisesQuery.value);
let routines = $derived(routinesQuery.value);
let workouts = $derived(workoutsQuery.value);
let sets = $derived(setsQuery.value);
let measurements = $derived(measurementsQuery.value);
let checks = $derived(checksQuery.value);
let phases = $derived(phasesQuery.value);
let meals = $derived(mealsQuery.value);
let activeWorkout = $derived(getActiveWorkout(workouts));
let activePhase = $derived(getActivePhase(phases));

View file

@ -0,0 +1,20 @@
<!--
Feedback — Workbench-embedded feedback/bug-report form.
-->
<script lang="ts">
import { FeedbackPage } from '@mana/feedback';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<div class="feedback-page">
<FeedbackPage {feedbackService} appName="Mana" currentUserId={authStore.user?.id} />
</div>
<style>
.feedback-page {
padding: 0.75rem;
height: 100%;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,39 @@
<!--
Help — Workbench-embedded help page with FAQ, guides, and support info.
-->
<script lang="ts">
import { locale } from 'svelte-i18n';
import { HelpPage, getHelpTranslations } from '@mana/help';
import { getManaHelpContent } from '$lib/content/help/index.js';
const content = $derived(getManaHelpContent($locale ?? 'de'));
const translations = $derived(
getHelpTranslations($locale ?? 'de', {
subtitle:
$locale === 'de'
? 'Finde Antworten und lerne Mana kennen'
: 'Find answers and learn how to use Mana',
})
);
</script>
<div class="help-page">
<HelpPage
{content}
appName="Mana"
appId="mana"
{translations}
showBackButton={false}
showGettingStarted={false}
showChangelog={false}
defaultSection="faq"
/>
</div>
<style>
.help-page {
padding: 0.75rem;
height: 100%;
overflow-y: auto;
}
</style>

View file

@ -3,10 +3,9 @@
Today's check-ins, week trend, emotion distribution, patterns, insights.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { MoodEntry, MoodSettings } from './types';
import {
useAllMoodEntries,
useMoodSettings,
getTodayEntries,
getAvgLevel,
getTopEmotion,
@ -21,14 +20,11 @@
import { EMOTION_META, ACTIVITY_LABELS } from './types';
import QuickLog from './components/QuickLog.svelte';
const entries$: Observable<MoodEntry[]> = getContext('moodEntries');
const settings$: Observable<MoodSettings | null> = getContext('moodSettings');
const entriesQuery = useAllMoodEntries();
const settingsQuery = useMoodSettings();
let entries = $state<MoodEntry[]>([]);
let settingsRaw = $state<MoodSettings | null>(null);
$effect(() => { const sub = entries$.subscribe((v) => (entries = v)); return () => sub.unsubscribe(); });
$effect(() => { const sub = settings$.subscribe((v) => (settingsRaw = v)); return () => sub.unsubscribe(); });
let entries = $derived(entriesQuery.value);
let settingsRaw = $derived(settingsQuery.value);
let settings = $derived(getEffectiveSettings(settingsRaw));
let todayEntries = $derived(getTodayEntries(entries));
@ -57,10 +53,7 @@
<div class="mood-view">
<!-- Inline Quick Log (expand/collapse) -->
{#if showQuickLog}
<QuickLog
onComplete={() => (showQuickLog = false)}
onCancel={() => (showQuickLog = false)}
/>
<QuickLog onComplete={() => (showQuickLog = false)} onCancel={() => (showQuickLog = false)} />
{:else}
<button class="log-cta" onclick={() => (showQuickLog = true)}>
<span class="cta-emoji">
@ -77,139 +70,145 @@
</button>
{/if}
<!-- Today's Entries -->
{#if todayEntries.length > 0}
<div class="today-section">
<span class="section-label">Heute</span>
<div class="today-entries">
{#each todayEntries as entry (entry.id)}
<div class="entry-pill">
<span class="ep-emoji">{EMOTION_META[entry.emotion]?.emoji ?? '😐'}</span>
<span class="ep-level" style:color={levelColor(entry.level)}>{entry.level}</span>
<span class="ep-time">{entry.time}</span>
{#if entry.activity}
<span class="ep-activity">{ACTIVITY_LABELS[entry.activity]?.emoji ?? ''}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Stats Row -->
<div class="stats-row">
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel7)}>{avgLevel7 || '—'}</span>
<span class="stat-lbl">Ø 7 Tage</span>
</div>
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel30)}>{avgLevel30 || '—'}</span>
<span class="stat-lbl">Ø 30 Tage</span>
</div>
<div class="stat">
<span class="stat-val">{streak}</span>
<span class="stat-lbl">Streak</span>
</div>
</div>
<!-- Week Mood Chart -->
{#if weekData.some((d) => d.avgLevel > 0)}
<div class="week-section">
<span class="section-label">Diese Woche</span>
<div class="week-chart">
{#each weekData as day}
<div class="week-col">
{#if day.avgLevel > 0}
<div class="week-dot" style:background={levelColor(day.avgLevel)} title="{String(day.avgLevel)}">
{day.avgLevel}
</div>
{:else}
<div class="week-dot empty"></div>
{/if}
<span class="week-label">{day.dayLabel}</span>
{#if day.count > 0}
<span class="week-count">{day.count}×</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Valence Bar -->
{#if entries.length >= 5}
<div class="valence-section">
<span class="section-label">Stimmungsbilanz</span>
<div class="valence-bar">
<div class="v-pos" style:width="{valence.positive}%"></div>
<div class="v-neu" style:width="{valence.neutral}%"></div>
<div class="v-neg" style:width="{valence.negative}%"></div>
</div>
<div class="valence-labels">
<span class="vl-pos">{valence.positive}% positiv</span>
<span class="vl-neg">{valence.negative}% negativ</span>
</div>
</div>
{/if}
<!-- Top Emotions -->
{#if distribution.length > 0}
<div class="dist-section">
<span class="section-label">Häufigste Emotionen</span>
<div class="dist-list">
{#each distribution.slice(0, 5) as item}
<div class="dist-row">
<span class="dist-emoji">{EMOTION_META[item.emotion]?.emoji ?? '😐'}</span>
<span class="dist-name">{EMOTION_META[item.emotion]?.de ?? item.emotion}</span>
<div class="dist-bar-track">
<div
class="dist-bar-fill"
style:width="{item.pct}%"
style:background={EMOTION_META[item.emotion]?.color ?? '#6b7280'}
></div>
</div>
<span class="dist-pct">{item.pct}%</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Weekday Pattern -->
{#if weekdayPattern.some((d) => d.avgLevel > 0)}
<div class="pattern-section">
<span class="section-label">Wochentag-Muster</span>
<div class="pattern-row">
{#each weekdayPattern as day}
<div class="pattern-col">
<div
class="pattern-dot"
class:empty={day.avgLevel === 0}
style:background={day.avgLevel > 0 ? levelColor(day.avgLevel) : ''}
>
{day.avgLevel > 0 ? day.avgLevel : ''}
</div>
<span class="pattern-label">{day.label}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Activity Insights -->
{#if activityInsights.length >= 2}
<div class="insights-section">
<span class="section-label">Aktivitäten & Stimmung</span>
{#each activityInsights.slice(0, 4) as insight}
<div class="insight-row">
<span class="ins-emoji">{ACTIVITY_LABELS[insight.activity]?.emoji ?? ''}</span>
<span class="ins-name">{ACTIVITY_LABELS[insight.activity]?.de ?? insight.activity}</span>
<span class="ins-val" style:color={levelColor(insight.avgLevel)}>Ø {insight.avgLevel}</span>
<span class="ins-count">({insight.count}×)</span>
<!-- Today's Entries -->
{#if todayEntries.length > 0}
<div class="today-section">
<span class="section-label">Heute</span>
<div class="today-entries">
{#each todayEntries as entry (entry.id)}
<div class="entry-pill">
<span class="ep-emoji">{EMOTION_META[entry.emotion]?.emoji ?? '😐'}</span>
<span class="ep-level" style:color={levelColor(entry.level)}>{entry.level}</span>
<span class="ep-time">{entry.time}</span>
{#if entry.activity}
<span class="ep-activity">{ACTIVITY_LABELS[entry.activity]?.emoji ?? ''}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Stats Row -->
<div class="stats-row">
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel7)}>{avgLevel7 || '—'}</span>
<span class="stat-lbl">Ø 7 Tage</span>
</div>
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel30)}>{avgLevel30 || '—'}</span>
<span class="stat-lbl">Ø 30 Tage</span>
</div>
<div class="stat">
<span class="stat-val">{streak}</span>
<span class="stat-lbl">Streak</span>
</div>
</div>
<!-- Week Mood Chart -->
{#if weekData.some((d) => d.avgLevel > 0)}
<div class="week-section">
<span class="section-label">Diese Woche</span>
<div class="week-chart">
{#each weekData as day}
<div class="week-col">
{#if day.avgLevel > 0}
<div
class="week-dot"
style:background={levelColor(day.avgLevel)}
title={String(day.avgLevel)}
>
{day.avgLevel}
</div>
{:else}
<div class="week-dot empty"></div>
{/if}
<span class="week-label">{day.dayLabel}</span>
{#if day.count > 0}
<span class="week-count">{day.count}×</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Valence Bar -->
{#if entries.length >= 5}
<div class="valence-section">
<span class="section-label">Stimmungsbilanz</span>
<div class="valence-bar">
<div class="v-pos" style:width="{valence.positive}%"></div>
<div class="v-neu" style:width="{valence.neutral}%"></div>
<div class="v-neg" style:width="{valence.negative}%"></div>
</div>
<div class="valence-labels">
<span class="vl-pos">{valence.positive}% positiv</span>
<span class="vl-neg">{valence.negative}% negativ</span>
</div>
</div>
{/if}
<!-- Top Emotions -->
{#if distribution.length > 0}
<div class="dist-section">
<span class="section-label">Häufigste Emotionen</span>
<div class="dist-list">
{#each distribution.slice(0, 5) as item}
<div class="dist-row">
<span class="dist-emoji">{EMOTION_META[item.emotion]?.emoji ?? '😐'}</span>
<span class="dist-name">{EMOTION_META[item.emotion]?.de ?? item.emotion}</span>
<div class="dist-bar-track">
<div
class="dist-bar-fill"
style:width="{item.pct}%"
style:background={EMOTION_META[item.emotion]?.color ?? '#6b7280'}
></div>
</div>
<span class="dist-pct">{item.pct}%</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Weekday Pattern -->
{#if weekdayPattern.some((d) => d.avgLevel > 0)}
<div class="pattern-section">
<span class="section-label">Wochentag-Muster</span>
<div class="pattern-row">
{#each weekdayPattern as day}
<div class="pattern-col">
<div
class="pattern-dot"
class:empty={day.avgLevel === 0}
style:background={day.avgLevel > 0 ? levelColor(day.avgLevel) : ''}
>
{day.avgLevel > 0 ? day.avgLevel : ''}
</div>
<span class="pattern-label">{day.label}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Activity Insights -->
{#if activityInsights.length >= 2}
<div class="insights-section">
<span class="section-label">Aktivitäten & Stimmung</span>
{#each activityInsights.slice(0, 4) as insight}
<div class="insight-row">
<span class="ins-emoji">{ACTIVITY_LABELS[insight.activity]?.emoji ?? ''}</span>
<span class="ins-name">{ACTIVITY_LABELS[insight.activity]?.de ?? insight.activity}</span>
<span class="ins-val" style:color={levelColor(insight.avgLevel)}
{insight.avgLevel}</span
>
<span class="ins-count">({insight.count}×)</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
@ -238,7 +237,9 @@
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
color: hsl(var(--color-foreground));
text-align: left;
}
@ -248,9 +249,21 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.cta-emoji { font-size: 1.375rem; flex-shrink: 0; }
.cta-text { font-size: 0.8125rem; font-weight: 600; flex: 1; }
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; flex-shrink: 0; }
.cta-emoji {
font-size: 1.375rem;
flex-shrink: 0;
}
.cta-text {
font-size: 0.8125rem;
font-weight: 600;
flex: 1;
}
.cta-sub {
font-size: 0.6875rem;
color: #f59e0b;
font-weight: 500;
flex-shrink: 0;
}
/* ── Today ────────────────────────────────────── */
.today-section {
@ -276,10 +289,20 @@
font-size: 0.75rem;
}
.ep-emoji { font-size: 0.875rem; }
.ep-level { font-weight: 700; font-variant-numeric: tabular-nums; }
.ep-time { color: hsl(var(--color-muted-foreground)); font-variant-numeric: tabular-nums; }
.ep-activity { font-size: 0.75rem; }
.ep-emoji {
font-size: 0.875rem;
}
.ep-level {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ep-time {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.ep-activity {
font-size: 0.75rem;
}
/* ── Stats ────────────────────────────────────── */
.stats-row {
@ -373,9 +396,15 @@
overflow: hidden;
}
.v-pos { background: #22c55e; }
.v-neu { background: #9ca3af; }
.v-neg { background: #ef4444; }
.v-pos {
background: #22c55e;
}
.v-neu {
background: #9ca3af;
}
.v-neg {
background: #ef4444;
}
.valence-labels {
display: flex;
@ -384,8 +413,12 @@
color: hsl(var(--color-muted-foreground));
}
.vl-pos { color: #22c55e; }
.vl-neg { color: #ef4444; }
.vl-pos {
color: #22c55e;
}
.vl-neg {
color: #ef4444;
}
/* ── Distribution ─────────────────────────────── */
.dist-section {
@ -407,8 +440,15 @@
font-size: 0.75rem;
}
.dist-emoji { font-size: 0.875rem; flex-shrink: 0; }
.dist-name { width: 5rem; flex-shrink: 0; color: hsl(var(--color-foreground)); }
.dist-emoji {
font-size: 0.875rem;
flex-shrink: 0;
}
.dist-name {
width: 5rem;
flex-shrink: 0;
color: hsl(var(--color-foreground));
}
.dist-bar-track {
flex: 1;
@ -489,8 +529,19 @@
padding: 0.25rem 0;
}
.ins-emoji { font-size: 0.875rem; }
.ins-name { flex: 1; color: hsl(var(--color-foreground)); }
.ins-val { font-weight: 600; font-variant-numeric: tabular-nums; }
.ins-count { font-size: 0.625rem; color: hsl(var(--color-muted-foreground)); }
.ins-emoji {
font-size: 0.875rem;
}
.ins-name {
flex: 1;
color: hsl(var(--color-foreground));
}
.ins-val {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ins-count {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,180 @@
<!--
Profile — Workbench-embedded profile page with account info,
edit/password/delete modals, and logout action.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@mana/shared-ui';
import type { UserProfile, ProfileActions } from '@mana/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
import {
EditProfileModal,
ChangePasswordModal,
DeleteAccountModal,
} from '$lib/components/profile';
let apiProfile = $state<ApiUserProfile | null>(null);
let loading = $state(true);
let showEditModal = $state(false);
let showPasswordModal = $state(false);
let showDeleteModal = $state(false);
let toastMessage = $state<string | null>(null);
onMount(async () => {
try {
apiProfile = await profileService.getProfile();
} catch (e) {
console.error('Failed to load profile:', e);
} finally {
loading = false;
}
});
let userProfile = $derived<UserProfile>({
id: apiProfile?.id || authStore.user?.id || '',
email: apiProfile?.email || authStore.user?.email || '',
displayName: apiProfile?.name || undefined,
role: apiProfile?.role || authStore.user?.role,
createdAt: apiProfile?.createdAt,
});
const actions: ProfileActions = {
onEditProfile: () => (showEditModal = true),
onChangePassword: () => (showPasswordModal = true),
onLogout: async () => {
await authStore.signOut();
goto('/login');
},
onDeleteAccount: () => (showDeleteModal = true),
};
function handleProfileUpdate(user: ApiUserProfile) {
apiProfile = user;
showToast('Profil erfolgreich aktualisiert');
}
function handlePasswordChange() {
showToast('Passwort erfolgreich geändert');
}
async function handleAccountDeleted() {
showToast('Konto wird gelöscht...');
await authStore.signOut();
goto('/login');
}
function showToast(message: string) {
toastMessage = message;
setTimeout(() => (toastMessage = null), 3000);
}
</script>
<div class="profile-page">
{#if loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
<ProfilePage
user={userProfile}
appName="Mana"
{actions}
pageTitle="Profil"
accountInfoTitle="Konto-Informationen"
actionsTitle="Aktionen"
emailLabel="E-Mail"
nameLabel="Name"
memberSinceLabel="Mitglied seit"
lastLoginLabel="Letzter Login"
roleLabel="Rolle"
editProfileLabel="Profil bearbeiten"
changePasswordLabel="Passwort ändern"
logoutLabel="Abmelden"
deleteAccountLabel="Konto löschen"
deleteAccountWarning="Diese Aktion kann nicht rückgängig gemacht werden."
/>
{/if}
</div>
<EditProfileModal
show={showEditModal}
user={apiProfile}
onClose={() => (showEditModal = false)}
onSuccess={handleProfileUpdate}
/>
<ChangePasswordModal
show={showPasswordModal}
onClose={() => (showPasswordModal = false)}
onSuccess={handlePasswordChange}
/>
<DeleteAccountModal
show={showDeleteModal}
userEmail={apiProfile?.email || authStore.user?.email || ''}
onClose={() => (showDeleteModal = false)}
onSuccess={handleAccountDeleted}
/>
{#if toastMessage}
<div class="toast">{toastMessage}</div>
{/if}
<style>
.profile-page {
padding: 0.75rem;
height: 100%;
overflow-y: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
padding: 0.75rem 1rem;
background: hsl(142 71% 45%);
color: white;
border-radius: 0.5rem;
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.15);
font-size: 0.875rem;
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,100 @@
<!--
Settings — Workbench-embedded settings panel with category sidebar,
search, and all setting sections (general, AI, security, credits, data).
Profile and Themes are separate workbench apps — not duplicated here.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { tick } from 'svelte';
import { APP_VERSION } from '$lib/version';
import { GlobalSettingsSection } from '@mana/shared-ui';
import { userSettings } from '$lib/stores/user-settings.svelte';
import SettingsSidebar from '$lib/components/settings/SettingsSidebar.svelte';
import {
categories,
type CategoryId,
type SearchEntry,
} from '$lib/components/settings/searchIndex';
import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import CreditsSection from '$lib/components/settings/sections/CreditsSection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte';
import SettingsPanel from '$lib/components/settings/SettingsPanel.svelte';
// Filter out 'profile' — it's a separate workbench app now
const workbenchCategories = categories.filter((c) => c.id !== 'profile');
let activeCategory = $state<CategoryId>('general');
onMount(() => {
void userSettings.load();
});
function jumpTo(entry: SearchEntry) {
if (entry.category === 'profile') return;
activeCategory = entry.category;
void tick().then(() => {
const target = document.getElementById(entry.anchor);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
</script>
<div class="settings-page">
<SettingsSidebar
{activeCategory}
onSelect={(id) => (activeCategory = id)}
onJump={jumpTo}
categories={workbenchCategories}
/>
<div class="settings-content">
{#if activeCategory === 'general'}
<SettingsPanel id="global" padded={false}>
<GlobalSettingsSection {userSettings} appId="mana" showTheme={false} />
</SettingsPanel>
{:else if activeCategory === 'ai'}
<AiSection />
{:else if activeCategory === 'security'}
<SecuritySection />
{:else if activeCategory === 'credits'}
<CreditsSection />
{:else if activeCategory === 'data'}
<DataSection />
{/if}
</div>
<p class="version">v{APP_VERSION}</p>
</div>
<style>
.settings-page {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
overflow-y: auto;
}
@media (min-width: 1024px) {
.settings-page {
flex-direction: row;
align-items: flex-start;
}
}
.settings-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.version {
text-align: center;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
padding-bottom: 0.5rem;
}
</style>

View file

@ -3,10 +3,11 @@
Last night summary, week bars, sleep goal, debt, stats, hygiene.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { SleepEntry, SleepHygieneLog, SleepHygieneCheck, SleepSettings } from './types';
import {
useAllSleepEntries,
useAllSleepHygieneLogs,
useAllSleepHygieneChecks,
useSleepSettings,
getLastNight,
hasLoggedToday,
getAvgDuration,
@ -25,32 +26,15 @@
import MorningLog from './components/MorningLog.svelte';
import HygieneChecklist from './components/HygieneChecklist.svelte';
const entries$: Observable<SleepEntry[]> = getContext('sleepEntries');
const hygieneLogs$: Observable<SleepHygieneLog[]> = getContext('sleepHygieneLogs');
const hygieneChecks$: Observable<SleepHygieneCheck[]> = getContext('sleepHygieneChecks');
const settings$: Observable<SleepSettings | null> = getContext('sleepSettings');
const entriesQuery = useAllSleepEntries();
const hygieneLogsQuery = useAllSleepHygieneLogs();
const hygieneChecksQuery = useAllSleepHygieneChecks();
const settingsQuery = useSleepSettings();
let entries = $state<SleepEntry[]>([]);
let hygieneLogs = $state<SleepHygieneLog[]>([]);
let hygieneChecks = $state<SleepHygieneCheck[]>([]);
let settingsRaw = $state<SleepSettings | null>(null);
$effect(() => {
const sub = entries$.subscribe((v) => (entries = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = hygieneLogs$.subscribe((v) => (hygieneLogs = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = hygieneChecks$.subscribe((v) => (hygieneChecks = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = settings$.subscribe((v) => (settingsRaw = v));
return () => sub.unsubscribe();
});
let entries = $derived(entriesQuery.value);
let hygieneLogs = $derived(hygieneLogsQuery.value);
let hygieneChecks = $derived(hygieneChecksQuery.value);
let settingsRaw = $derived(settingsQuery.value);
let settings = $derived(getEffectiveSettings(settingsRaw));
let lastNight = $derived(getLastNight(entries));

View file

@ -3,16 +3,12 @@
Streak, quick-start routines, assessment recommendation, recent sessions.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type {
StretchExercise,
StretchRoutine,
StretchSession,
StretchAssessment,
StretchReminder,
} from './types';
import {
useAllStretchExercises,
useAllStretchRoutines,
useAllStretchSessions,
useAllStretchAssessments,
useAllStretchReminders,
getCurrentStreak,
getTodayMinutes,
getWeekSessionCount,
@ -30,38 +26,17 @@
import ReminderManager from './components/ReminderManager.svelte';
import SessionHistory from './components/SessionHistory.svelte';
const exercises$: Observable<StretchExercise[]> = getContext('stretchExercises');
const routines$: Observable<StretchRoutine[]> = getContext('stretchRoutines');
const sessions$: Observable<StretchSession[]> = getContext('stretchSessions');
const assessments$: Observable<StretchAssessment[]> = getContext('stretchAssessments');
const reminders$: Observable<StretchReminder[]> = getContext('stretchReminders');
const exercisesQuery = useAllStretchExercises();
const routinesQuery = useAllStretchRoutines();
const sessionsQuery = useAllStretchSessions();
const assessmentsQuery = useAllStretchAssessments();
const remindersQuery = useAllStretchReminders();
let exercises = $state<StretchExercise[]>([]);
let routines = $state<StretchRoutine[]>([]);
let sessions = $state<StretchSession[]>([]);
let assessments = $state<StretchAssessment[]>([]);
let reminders = $state<StretchReminder[]>([]);
$effect(() => {
const sub = exercises$.subscribe((v) => (exercises = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = routines$.subscribe((v) => (routines = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = sessions$.subscribe((v) => (sessions = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = assessments$.subscribe((v) => (assessments = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = reminders$.subscribe((v) => (reminders = v));
return () => sub.unsubscribe();
});
let exercises = $derived(exercisesQuery.value);
let routines = $derived(routinesQuery.value);
let sessions = $derived(sessionsQuery.value);
let assessments = $derived(assessmentsQuery.value);
let reminders = $derived(remindersQuery.value);
let streak = $derived(getCurrentStreak(sessions));
let todayMinutes = $derived(Math.round(getTodayMinutes(sessions)));

View file

@ -0,0 +1,792 @@
<!--
Subscription — Workbench-embedded subscription management with plan
selection, current status, billing interval toggle, and invoice history.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { Check } from '@mana/shared-icons';
import {
subscriptionsService,
type SubscriptionPlan,
type CurrentSubscription,
type Invoice,
} from '$lib/api/subscriptions';
let plans = $state<SubscriptionPlan[]>([]);
let currentSubscription = $state<CurrentSubscription | null>(null);
let invoices = $state<Invoice[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let activeTab = $state<'plans' | 'invoices'>('plans');
let billingInterval = $state<'month' | 'year'>('month');
let processingPlanId = $state<string | null>(null);
let cancelingSubscription = $state(false);
let reactivatingSubscription = $state(false);
let openingPortal = $state(false);
let toastMessage = $state<string | null>(null);
let toastType = $state<'success' | 'error'>('success');
onMount(async () => {
await loadData();
});
async function loadData() {
loading = true;
error = null;
try {
const [plansData, subscriptionData, invoicesData] = await Promise.all([
subscriptionsService.getPlans(),
subscriptionsService.getCurrentSubscription(),
subscriptionsService.getInvoices(10),
]);
plans = plansData.filter((p) => p.active).sort((a, b) => a.sortOrder - b.sortOrder);
currentSubscription = subscriptionData;
invoices = invoicesData;
} catch (e) {
error = e instanceof Error ? e.message : $_('common.error_loading');
} finally {
loading = false;
}
}
function formatPrice(cents: number): string {
return (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function formatMonthlyEquivalent(yearlyCents: number): string {
return (yearlyCents / 12 / 100).toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
});
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function getStatusLabel(status: string): string {
const map: Record<string, string> = {
active: 'Aktiv',
canceled: 'Gekündigt',
past_due: 'Überfällig',
trialing: 'Testphase',
};
return map[status] || status;
}
function getSavingsPercent(monthly: number, yearly: number): number {
const full = monthly * 12;
if (full === 0) return 0;
return Math.round(((full - yearly) / full) * 100);
}
async function handleSelectPlan(plan: SubscriptionPlan) {
if (plan.isDefault) return;
processingPlanId = plan.id;
try {
const { url } = await subscriptionsService.createCheckout(plan.id, billingInterval);
window.location.href = url;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Checkout', 'error');
} finally {
processingPlanId = null;
}
}
async function handleOpenPortal() {
openingPortal = true;
try {
const { url } = await subscriptionsService.openPortal();
window.location.href = url;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Billing-Portal', 'error');
} finally {
openingPortal = false;
}
}
async function handleCancelSubscription() {
if (!confirm('Möchtest du dein Abonnement wirklich kündigen?')) return;
cancelingSubscription = true;
try {
await subscriptionsService.cancelSubscription();
showToast('Abonnement wird zum Ende der Laufzeit gekündigt', 'success');
await loadData();
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Kündigen', 'error');
} finally {
cancelingSubscription = false;
}
}
async function handleReactivateSubscription() {
reactivatingSubscription = true;
try {
await subscriptionsService.reactivateSubscription();
showToast('Abonnement wurde reaktiviert', 'success');
await loadData();
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Reaktivieren', 'error');
} finally {
reactivatingSubscription = false;
}
}
function showToast(message: string, type: 'success' | 'error') {
toastMessage = message;
toastType = type;
setTimeout(() => (toastMessage = null), 4000);
}
</script>
<div class="sub-page">
{#if loading}
<div class="loading"><div class="spinner"></div></div>
{:else if error}
<div class="error-box">
<p>{error}</p>
<button class="retry-btn" onclick={loadData}>Erneut versuchen</button>
</div>
{:else}
<!-- Current Subscription -->
{#if currentSubscription?.subscription}
{@const sub = currentSubscription.subscription}
{@const plan = currentSubscription.plan}
<div class="status-card">
<div class="status-header">
<div>
<div class="status-title-row">
<span class="plan-name">{plan?.name || 'Aktueller Plan'}</span>
<span
class="status-badge"
class:active={sub.status === 'active'}
class:canceled={sub.status === 'canceled'}
class:past-due={sub.status === 'past_due'}
>
{getStatusLabel(sub.status)}
</span>
</div>
<span class="plan-credits">
{plan?.monthlyCredits.toLocaleString('de-DE')} Mana / Monat
</span>
</div>
<button class="portal-btn" disabled={openingPortal} onclick={handleOpenPortal}>
{openingPortal ? '...' : 'Zahlungsmethode'}
</button>
</div>
<div class="status-details">
<div class="detail">
<span class="detail-label">Zeitraum</span>
<span>{sub.billingInterval === 'year' ? 'Jährlich' : 'Monatlich'}</span>
</div>
<div class="detail">
<span class="detail-label">Periode</span>
<span>{formatDate(sub.currentPeriodStart)} {formatDate(sub.currentPeriodEnd)}</span>
</div>
<div class="detail">
{#if sub.cancelAtPeriodEnd}
<span class="detail-label">Endet am</span>
<span class="text-warn">{formatDate(sub.currentPeriodEnd)}</span>
<button
class="link-btn"
disabled={reactivatingSubscription}
onclick={handleReactivateSubscription}
>
{reactivatingSubscription ? '...' : 'Reaktivieren'}
</button>
{:else}
<span class="detail-label">Verlängert am</span>
<span>{formatDate(sub.currentPeriodEnd)}</span>
<button
class="link-btn danger"
disabled={cancelingSubscription}
onclick={handleCancelSubscription}
>
{cancelingSubscription ? '...' : 'Kündigen'}
</button>
{/if}
</div>
</div>
</div>
{:else}
<div class="status-card">
<div class="status-title-row">
<span class="plan-name">Free Plan</span>
<span class="status-badge active">Aktuell</span>
</div>
<span class="plan-credits">150 Mana / Monat</span>
</div>
{/if}
<!-- Tabs -->
<div class="tabs">
<button class="tab" class:active={activeTab === 'plans'} onclick={() => (activeTab = 'plans')}
>Pläne</button
>
<button
class="tab"
class:active={activeTab === 'invoices'}
onclick={() => (activeTab = 'invoices')}>Rechnungen</button
>
</div>
{#if activeTab === 'plans'}
<!-- Billing toggle -->
<div class="interval-toggle">
<button
class="interval-btn"
class:selected={billingInterval === 'month'}
onclick={() => (billingInterval = 'month')}>Monatlich</button
>
<button
class="interval-btn"
class:selected={billingInterval === 'year'}
onclick={() => (billingInterval = 'year')}
>
Jährlich <span class="save-tag">17%</span>
</button>
</div>
<!-- Plans -->
<div class="plans-list">
{#each plans as plan}
{@const isCurrent = currentSubscription?.plan?.id === plan.id}
{@const price =
billingInterval === 'year' ? plan.priceYearlyEuroCents : plan.priceMonthlyEuroCents}
<div class="plan-card" class:current={isCurrent}>
{#if isCurrent}<span class="current-tag">Dein Plan</span>{/if}
<span class="plan-card-name">{plan.name}</span>
{#if plan.description}
<span class="plan-desc">{plan.description}</span>
{/if}
<div class="plan-price">
<span class="price-amount">
{plan.isDefault ? 'Kostenlos' : formatPrice(price)}
</span>
{#if !plan.isDefault}
<span class="price-period">/ {billingInterval === 'year' ? 'Jahr' : 'Monat'}</span>
{#if billingInterval === 'year'}
<span class="price-monthly"
>{formatMonthlyEquivalent(plan.priceYearlyEuroCents)} / Monat</span
>
{/if}
{/if}
</div>
<span class="plan-mana">
{plan.monthlyCredits.toLocaleString('de-DE')} Mana / Monat
</span>
{#if plan.features?.length}
<ul class="features">
{#each plan.features as feature}
<li><Check size={14} /> {feature}</li>
{/each}
</ul>
{/if}
<button
class="select-btn"
class:disabled={isCurrent || plan.isDefault}
disabled={isCurrent || processingPlanId === plan.id || plan.isDefault}
onclick={() => handleSelectPlan(plan)}
>
{#if processingPlanId === plan.id}
...
{:else if isCurrent}
Aktuell
{:else if plan.isDefault}
Kostenlos
{:else}
Auswählen
{/if}
</button>
</div>
{/each}
</div>
{:else}
<!-- Invoices -->
<div class="invoices">
{#if invoices.length === 0}
<p class="empty">Noch keine Rechnungen vorhanden.</p>
{:else}
{#each invoices as inv}
<div class="invoice-row">
<div class="invoice-info">
<span class="invoice-number">{inv.number || '-'}</span>
<span class="invoice-date">{formatDate(inv.createdAt)}</span>
</div>
<div class="invoice-right">
<span class="invoice-amount">{formatPrice(inv.amountPaidEuroCents)}</span>
<span class="invoice-status" class:paid={inv.status === 'paid'}>
{inv.status === 'paid' ? 'Bezahlt' : inv.status}
</span>
{#if inv.invoicePdfUrl}
<a
href={inv.invoicePdfUrl}
target="_blank"
rel="noopener noreferrer"
class="pdf-link">PDF</a
>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/if}
{/if}
</div>
{#if toastMessage}
<div class="toast" class:error={toastType === 'error'}>{toastMessage}</div>
{/if}
<style>
.sub-page {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
overflow-y: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-box {
text-align: center;
padding: 2rem;
}
.error-box p {
color: hsl(0 84% 60%);
font-size: 0.8125rem;
margin-bottom: 0.75rem;
}
.retry-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.8125rem;
cursor: pointer;
}
/* Status card */
.status-card {
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.status-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.status-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.plan-name {
font-size: 0.9375rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.status-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
}
.status-badge.active {
background: hsl(142 71% 45% / 0.12);
color: hsl(142 71% 45%);
}
.status-badge.canceled {
background: hsl(45 93% 47% / 0.12);
color: hsl(45 93% 47%);
}
.status-badge.past-due {
background: hsl(0 84% 60% / 0.12);
color: hsl(0 84% 60%);
}
.plan-credits {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.portal-btn {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
font-size: 0.6875rem;
cursor: pointer;
color: hsl(var(--color-foreground));
}
.portal-btn:hover {
background: hsl(var(--color-surface-hover));
}
.status-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border));
}
.detail {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.detail-label {
color: hsl(var(--color-muted-foreground));
min-width: 5rem;
}
.text-warn {
color: hsl(45 93% 47%);
}
.link-btn {
background: none;
border: none;
font-size: 0.6875rem;
color: hsl(var(--color-primary));
cursor: pointer;
text-decoration: underline;
}
.link-btn.danger {
color: hsl(0 84% 60%);
}
.link-btn:disabled {
opacity: 0.5;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.tab {
padding: 0.5rem 0.75rem;
border: none;
background: none;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active {
color: hsl(var(--color-primary));
border-bottom-color: hsl(var(--color-primary));
}
/* Interval toggle */
.interval-toggle {
display: flex;
gap: 0.25rem;
justify-content: center;
padding: 0.25rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: 0.5rem;
}
.interval-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.interval-btn.selected {
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.08);
}
.save-tag {
font-size: 0.625rem;
color: hsl(142 71% 45%);
}
/* Plans */
.plans-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.plan-card {
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.plan-card.current {
border-color: hsl(var(--color-primary) / 0.35);
}
.current-tag {
font-size: 0.625rem;
font-weight: 600;
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
align-self: flex-start;
}
.plan-card-name {
font-size: 0.9375rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.plan-desc {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.plan-price {
display: flex;
align-items: baseline;
gap: 0.25rem;
flex-wrap: wrap;
}
.price-amount {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.price-period {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.price-monthly {
font-size: 0.6875rem;
color: hsl(142 71% 45%);
}
.plan-mana {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-primary));
}
.features {
list-style: none;
padding: 0;
margin: 0.375rem 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.features li {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.features li :global(svg) {
color: hsl(142 71% 45%);
flex-shrink: 0;
}
.select-btn {
margin-top: 0.5rem;
padding: 0.5rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.select-btn:hover {
opacity: 0.9;
}
.select-btn.disabled {
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
cursor: not-allowed;
}
/* Invoices */
.invoices {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.empty {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
padding: 2rem;
}
.invoice-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.invoice-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.invoice-number {
font-family: monospace;
font-size: 0.75rem;
}
.invoice-date {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.invoice-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.invoice-amount {
font-size: 0.8125rem;
font-weight: 500;
}
.invoice-status {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(45 93% 47% / 0.12);
color: hsl(45 93% 47%);
}
.invoice-status.paid {
background: hsl(142 71% 45% / 0.12);
color: hsl(142 71% 45%);
}
.pdf-link {
font-size: 0.6875rem;
color: hsl(var(--color-primary));
text-decoration: none;
}
.pdf-link:hover {
text-decoration: underline;
}
/* Toast */
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
padding: 0.75rem 1rem;
background: hsl(142 71% 45%);
color: white;
border-radius: 0.5rem;
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.15);
font-size: 0.875rem;
animation: fade-in 0.2s ease-out;
}
.toast.error {
background: hsl(0 84% 60%);
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,48 @@
<!--
Themes — Workbench-embedded theme picker with variant selection,
light/dark mode toggle, and wallpaper picker.
-->
<script lang="ts">
import { ThemePage } from '@mana/shared-theme-ui';
import { theme } from '$lib/stores/theme';
import { wallpaperStore } from '$lib/stores/wallpaper.svelte';
import WallpaperPicker from '$lib/components/wallpaper/WallpaperPicker.svelte';
</script>
<div class="themes-page">
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={false}
transparent={wallpaperStore.hasWallpaper}
>
<section class="wallpaper-section">
<h2 class="wallpaper-heading">Hintergrund</h2>
<WallpaperPicker />
</section>
</ThemePage>
</div>
<style>
.themes-page {
padding: 0.75rem;
height: 100%;
overflow-y: auto;
}
.wallpaper-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid hsl(var(--color-border));
}
.wallpaper-heading {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin-bottom: 1rem;
}
</style>