mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
51c8a52811
commit
9ff2cfcdac
16 changed files with 2819 additions and 341 deletions
|
|
@ -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') },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
115
apps/mana/apps/web/src/lib/app-registry/categories.ts
Normal file
115
apps/mana/apps/web/src/lib/app-registry/categories.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
264
apps/mana/apps/web/src/lib/modules/admin/ListView.svelte
Normal file
264
apps/mana/apps/web/src/lib/modules/admin/ListView.svelte
Normal 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>
|
||||
685
apps/mana/apps/web/src/lib/modules/api-keys/ListView.svelte
Normal file
685
apps/mana/apps/web/src/lib/modules/api-keys/ListView.svelte
Normal 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>
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
20
apps/mana/apps/web/src/lib/modules/feedback/ListView.svelte
Normal file
20
apps/mana/apps/web/src/lib/modules/feedback/ListView.svelte
Normal 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>
|
||||
39
apps/mana/apps/web/src/lib/modules/help/ListView.svelte
Normal file
39
apps/mana/apps/web/src/lib/modules/help/ListView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
180
apps/mana/apps/web/src/lib/modules/profile/ListView.svelte
Normal file
180
apps/mana/apps/web/src/lib/modules/profile/ListView.svelte
Normal 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>
|
||||
100
apps/mana/apps/web/src/lib/modules/settings/ListView.svelte
Normal file
100
apps/mana/apps/web/src/lib/modules/settings/ListView.svelte
Normal 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>
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
792
apps/mana/apps/web/src/lib/modules/subscription/ListView.svelte
Normal file
792
apps/mana/apps/web/src/lib/modules/subscription/ListView.svelte
Normal 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>
|
||||
48
apps/mana/apps/web/src/lib/modules/themes/ListView.svelte
Normal file
48
apps/mana/apps/web/src/lib/modules/themes/ListView.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue