mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 16:32:53 +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,
|
Robot,
|
||||||
Target,
|
Target,
|
||||||
Smiley,
|
Smiley,
|
||||||
|
Gear,
|
||||||
|
Palette,
|
||||||
|
UserCircle,
|
||||||
|
ShieldCheck,
|
||||||
|
Key,
|
||||||
|
Question,
|
||||||
|
ChatCircleDots,
|
||||||
|
CreditCard,
|
||||||
} from '@mana/shared-icons';
|
} from '@mana/shared-icons';
|
||||||
|
|
||||||
// ── Apps with entity capabilities ───────────────────────────
|
// ── Apps with entity capabilities ───────────────────────────
|
||||||
|
|
@ -977,3 +985,85 @@ registerApp({
|
||||||
list: { load: () => import('$lib/modules/goals/ListView.svelte') },
|
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">
|
<script lang="ts">
|
||||||
import { MagnifyingGlass, X } from '@mana/shared-icons';
|
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 {
|
interface Props {
|
||||||
activeCategory: CategoryId;
|
activeCategory: CategoryId;
|
||||||
onSelect: (id: CategoryId) => void;
|
onSelect: (id: CategoryId) => void;
|
||||||
onJump: (entry: SearchEntry) => 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 query = $state('');
|
||||||
let results = $derived(searchSettings(query));
|
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">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { tick } from 'svelte';
|
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 PickerOverlay from '$lib/components/PickerOverlay.svelte';
|
||||||
import { getAccessibleApps } from '$lib/app-registry';
|
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 {
|
function appName(id: string, fallback: string): string {
|
||||||
const key = `apps.${id}`;
|
const key = `apps.${id}`;
|
||||||
|
|
@ -28,68 +33,156 @@
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let searchInput = $state<HTMLInputElement | null>(null);
|
let searchInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Filter twice: tier-gate first (so guests + public users don't see
|
// Collapsed state per category — persist across openings in-session.
|
||||||
// founder/alpha/beta apps at all), then drop apps that are already
|
let collapsed = $state<Record<AppCategory, boolean>>({
|
||||||
// open in the current scene. Sort alphabetically by the displayed
|
companion: false,
|
||||||
// (i18n-resolved) name, then apply the search query.
|
life: false,
|
||||||
let availableApps = $derived(
|
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)
|
getAccessibleApps(userTier)
|
||||||
.filter((app) => !activeAppIds.includes(app.id))
|
.filter((app) => !activeAppIds.includes(app.id))
|
||||||
.map((app) => ({ app, displayName: appName(app.id, app.name) }))
|
.map((app) => ({ app, displayName: appName(app.id, app.name) }))
|
||||||
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'de'))
|
.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(() => {
|
$effect(() => {
|
||||||
tick().then(() => searchInput?.focus());
|
tick().then(() => searchInput?.focus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleCategory(id: AppCategory) {
|
||||||
|
collapsed[id] = !collapsed[id];
|
||||||
|
}
|
||||||
|
|
||||||
function handleSearchKeydown(e: KeyboardEvent) {
|
function handleSearchKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' && availableApps.length > 0) {
|
if (e.key === 'Enter' && searchResults.length > 0) {
|
||||||
e.preventDefault();
|
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>
|
</script>
|
||||||
|
|
||||||
<PickerOverlay
|
<div class="app-picker-wrapper">
|
||||||
title="App hinzufügen"
|
<PickerOverlay
|
||||||
items={availableApps}
|
title="App hinzufügen"
|
||||||
{onClose}
|
items={rows}
|
||||||
width="300px"
|
{onClose}
|
||||||
emptyLabel={query.trim() === '' ? 'Alle Apps sind bereits geöffnet' : 'Keine Treffer'}
|
width="320px"
|
||||||
>
|
emptyLabel={searchMode ? 'Keine Treffer' : 'Alle Apps sind bereits geöffnet'}
|
||||||
{#snippet subheader()}
|
>
|
||||||
<div class="search-wrap">
|
{#snippet subheader()}
|
||||||
<MagnifyingGlass size={14} />
|
<div class="search-wrap">
|
||||||
<input
|
<MagnifyingGlass size={14} />
|
||||||
bind:this={searchInput}
|
<input
|
||||||
bind:value={query}
|
bind:this={searchInput}
|
||||||
type="text"
|
bind:value={query}
|
||||||
placeholder="Suchen…"
|
type="text"
|
||||||
class="search-input"
|
placeholder="Suchen…"
|
||||||
onkeydown={handleSearchKeydown}
|
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>
|
</div>
|
||||||
<span class="app-name">{appName(app.id, app.name)}</span>
|
{/snippet}
|
||||||
</button>
|
{#snippet item(row)}
|
||||||
{/snippet}
|
{#if row.kind === 'header'}
|
||||||
</PickerOverlay>
|
{@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>
|
<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 {
|
.search-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -116,6 +209,73 @@
|
||||||
color: hsl(var(--color-muted-foreground));
|
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) {
|
:global(.picker .app-icon-wrap) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -125,7 +285,7 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: hsl(var(--color-muted-foreground));
|
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));
|
color: hsl(var(--color-foreground));
|
||||||
}
|
}
|
||||||
:global(.picker .app-name) {
|
: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.
|
daily energy/sleep/soreness/mood card; recent workouts.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { Observable } from 'dexie';
|
import {
|
||||||
import type {
|
useAllBodyExercises,
|
||||||
BodyExercise,
|
useAllBodyRoutines,
|
||||||
BodyRoutine,
|
useAllBodyWorkouts,
|
||||||
BodyWorkout,
|
useAllBodySets,
|
||||||
BodySet,
|
useAllBodyMeasurements,
|
||||||
BodyMeasurement,
|
useAllBodyChecks,
|
||||||
BodyCheck,
|
useAllBodyPhases,
|
||||||
BodyPhase,
|
useNutriphiMealsSince,
|
||||||
} from './types';
|
dateNDaysAgo,
|
||||||
import type { MealWithNutrition } from '$lib/modules/nutriphi/types';
|
getActiveWorkout,
|
||||||
import { getActiveWorkout, getActivePhase } from './queries';
|
getActivePhase,
|
||||||
|
} from './queries';
|
||||||
import { bodyStore } from './stores/body.svelte';
|
import { bodyStore } from './stores/body.svelte';
|
||||||
import WorkoutLogger from './components/WorkoutLogger.svelte';
|
import WorkoutLogger from './components/WorkoutLogger.svelte';
|
||||||
import MeasurementForm from './components/MeasurementForm.svelte';
|
import MeasurementForm from './components/MeasurementForm.svelte';
|
||||||
|
|
@ -31,56 +31,23 @@
|
||||||
import ExerciseProgressionChart from './components/ExerciseProgressionChart.svelte';
|
import ExerciseProgressionChart from './components/ExerciseProgressionChart.svelte';
|
||||||
import CalorieWeightChart from './components/CalorieWeightChart.svelte';
|
import CalorieWeightChart from './components/CalorieWeightChart.svelte';
|
||||||
|
|
||||||
const exercises$: Observable<BodyExercise[]> = getContext('bodyExercises');
|
const exercisesQuery = useAllBodyExercises();
|
||||||
const routines$: Observable<BodyRoutine[]> = getContext('bodyRoutines');
|
const routinesQuery = useAllBodyRoutines();
|
||||||
const workouts$: Observable<BodyWorkout[]> = getContext('bodyWorkouts');
|
const workoutsQuery = useAllBodyWorkouts();
|
||||||
const sets$: Observable<BodySet[]> = getContext('bodySets');
|
const setsQuery = useAllBodySets();
|
||||||
const measurements$: Observable<BodyMeasurement[]> = getContext('bodyMeasurements');
|
const measurementsQuery = useAllBodyMeasurements();
|
||||||
const checks$: Observable<BodyCheck[]> = getContext('bodyChecks');
|
const checksQuery = useAllBodyChecks();
|
||||||
const phases$: Observable<BodyPhase[]> = getContext('bodyPhases');
|
const phasesQuery = useAllBodyPhases();
|
||||||
const meals$: Observable<MealWithNutrition[]> = getContext('bodyNutriphiMeals');
|
const mealsQuery = useNutriphiMealsSince(dateNDaysAgo(56));
|
||||||
|
|
||||||
let exercises = $state<BodyExercise[]>([]);
|
let exercises = $derived(exercisesQuery.value);
|
||||||
let routines = $state<BodyRoutine[]>([]);
|
let routines = $derived(routinesQuery.value);
|
||||||
let workouts = $state<BodyWorkout[]>([]);
|
let workouts = $derived(workoutsQuery.value);
|
||||||
let sets = $state<BodySet[]>([]);
|
let sets = $derived(setsQuery.value);
|
||||||
let measurements = $state<BodyMeasurement[]>([]);
|
let measurements = $derived(measurementsQuery.value);
|
||||||
let checks = $state<BodyCheck[]>([]);
|
let checks = $derived(checksQuery.value);
|
||||||
let phases = $state<BodyPhase[]>([]);
|
let phases = $derived(phasesQuery.value);
|
||||||
let meals = $state<MealWithNutrition[]>([]);
|
let meals = $derived(mealsQuery.value);
|
||||||
|
|
||||||
$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 activeWorkout = $derived(getActiveWorkout(workouts));
|
let activeWorkout = $derived(getActiveWorkout(workouts));
|
||||||
let activePhase = $derived(getActivePhase(phases));
|
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.
|
Today's check-ins, week trend, emotion distribution, patterns, insights.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import type { Observable } from 'dexie';
|
|
||||||
import type { MoodEntry, MoodSettings } from './types';
|
|
||||||
import {
|
import {
|
||||||
|
useAllMoodEntries,
|
||||||
|
useMoodSettings,
|
||||||
getTodayEntries,
|
getTodayEntries,
|
||||||
getAvgLevel,
|
getAvgLevel,
|
||||||
getTopEmotion,
|
getTopEmotion,
|
||||||
|
|
@ -21,14 +20,11 @@
|
||||||
import { EMOTION_META, ACTIVITY_LABELS } from './types';
|
import { EMOTION_META, ACTIVITY_LABELS } from './types';
|
||||||
import QuickLog from './components/QuickLog.svelte';
|
import QuickLog from './components/QuickLog.svelte';
|
||||||
|
|
||||||
const entries$: Observable<MoodEntry[]> = getContext('moodEntries');
|
const entriesQuery = useAllMoodEntries();
|
||||||
const settings$: Observable<MoodSettings | null> = getContext('moodSettings');
|
const settingsQuery = useMoodSettings();
|
||||||
|
|
||||||
let entries = $state<MoodEntry[]>([]);
|
let entries = $derived(entriesQuery.value);
|
||||||
let settingsRaw = $state<MoodSettings | null>(null);
|
let settingsRaw = $derived(settingsQuery.value);
|
||||||
|
|
||||||
$effect(() => { const sub = entries$.subscribe((v) => (entries = v)); return () => sub.unsubscribe(); });
|
|
||||||
$effect(() => { const sub = settings$.subscribe((v) => (settingsRaw = v)); return () => sub.unsubscribe(); });
|
|
||||||
|
|
||||||
let settings = $derived(getEffectiveSettings(settingsRaw));
|
let settings = $derived(getEffectiveSettings(settingsRaw));
|
||||||
let todayEntries = $derived(getTodayEntries(entries));
|
let todayEntries = $derived(getTodayEntries(entries));
|
||||||
|
|
@ -57,10 +53,7 @@
|
||||||
<div class="mood-view">
|
<div class="mood-view">
|
||||||
<!-- Inline Quick Log (expand/collapse) -->
|
<!-- Inline Quick Log (expand/collapse) -->
|
||||||
{#if showQuickLog}
|
{#if showQuickLog}
|
||||||
<QuickLog
|
<QuickLog onComplete={() => (showQuickLog = false)} onCancel={() => (showQuickLog = false)} />
|
||||||
onComplete={() => (showQuickLog = false)}
|
|
||||||
onCancel={() => (showQuickLog = false)}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button class="log-cta" onclick={() => (showQuickLog = true)}>
|
<button class="log-cta" onclick={() => (showQuickLog = true)}>
|
||||||
<span class="cta-emoji">
|
<span class="cta-emoji">
|
||||||
|
|
@ -77,139 +70,145 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Today's Entries -->
|
<!-- Today's Entries -->
|
||||||
{#if todayEntries.length > 0}
|
{#if todayEntries.length > 0}
|
||||||
<div class="today-section">
|
<div class="today-section">
|
||||||
<span class="section-label">Heute</span>
|
<span class="section-label">Heute</span>
|
||||||
<div class="today-entries">
|
<div class="today-entries">
|
||||||
{#each todayEntries as entry (entry.id)}
|
{#each todayEntries as entry (entry.id)}
|
||||||
<div class="entry-pill">
|
<div class="entry-pill">
|
||||||
<span class="ep-emoji">{EMOTION_META[entry.emotion]?.emoji ?? '😐'}</span>
|
<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-level" style:color={levelColor(entry.level)}>{entry.level}</span>
|
||||||
<span class="ep-time">{entry.time}</span>
|
<span class="ep-time">{entry.time}</span>
|
||||||
{#if entry.activity}
|
{#if entry.activity}
|
||||||
<span class="ep-activity">{ACTIVITY_LABELS[entry.activity]?.emoji ?? ''}</span>
|
<span class="ep-activity">{ACTIVITY_LABELS[entry.activity]?.emoji ?? ''}</span>
|
||||||
{/if}
|
{/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>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -238,7 +237,9 @@
|
||||||
background: hsl(var(--color-muted));
|
background: hsl(var(--color-muted));
|
||||||
border: 1px solid hsl(var(--color-border));
|
border: 1px solid hsl(var(--color-border));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
color: hsl(var(--color-foreground));
|
color: hsl(var(--color-foreground));
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -248,9 +249,21 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-emoji { font-size: 1.375rem; flex-shrink: 0; }
|
.cta-emoji {
|
||||||
.cta-text { font-size: 0.8125rem; font-weight: 600; flex: 1; }
|
font-size: 1.375rem;
|
||||||
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; flex-shrink: 0; }
|
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 ────────────────────────────────────── */
|
||||||
.today-section {
|
.today-section {
|
||||||
|
|
@ -276,10 +289,20 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ep-emoji { font-size: 0.875rem; }
|
.ep-emoji {
|
||||||
.ep-level { font-weight: 700; font-variant-numeric: tabular-nums; }
|
font-size: 0.875rem;
|
||||||
.ep-time { color: hsl(var(--color-muted-foreground)); font-variant-numeric: tabular-nums; }
|
}
|
||||||
.ep-activity { font-size: 0.75rem; }
|
.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 ────────────────────────────────────── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
|
|
@ -373,9 +396,15 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-pos { background: #22c55e; }
|
.v-pos {
|
||||||
.v-neu { background: #9ca3af; }
|
background: #22c55e;
|
||||||
.v-neg { background: #ef4444; }
|
}
|
||||||
|
.v-neu {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
.v-neg {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.valence-labels {
|
.valence-labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -384,8 +413,12 @@
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.vl-pos { color: #22c55e; }
|
.vl-pos {
|
||||||
.vl-neg { color: #ef4444; }
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.vl-neg {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Distribution ─────────────────────────────── */
|
/* ── Distribution ─────────────────────────────── */
|
||||||
.dist-section {
|
.dist-section {
|
||||||
|
|
@ -407,8 +440,15 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-emoji { font-size: 0.875rem; flex-shrink: 0; }
|
.dist-emoji {
|
||||||
.dist-name { width: 5rem; flex-shrink: 0; color: hsl(var(--color-foreground)); }
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dist-name {
|
||||||
|
width: 5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.dist-bar-track {
|
.dist-bar-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -489,8 +529,19 @@
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ins-emoji { font-size: 0.875rem; }
|
.ins-emoji {
|
||||||
.ins-name { flex: 1; color: hsl(var(--color-foreground)); }
|
font-size: 0.875rem;
|
||||||
.ins-val { font-weight: 600; font-variant-numeric: tabular-nums; }
|
}
|
||||||
.ins-count { font-size: 0.625rem; color: hsl(var(--color-muted-foreground)); }
|
.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>
|
</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.
|
Last night summary, week bars, sleep goal, debt, stats, hygiene.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import type { Observable } from 'dexie';
|
|
||||||
import type { SleepEntry, SleepHygieneLog, SleepHygieneCheck, SleepSettings } from './types';
|
|
||||||
import {
|
import {
|
||||||
|
useAllSleepEntries,
|
||||||
|
useAllSleepHygieneLogs,
|
||||||
|
useAllSleepHygieneChecks,
|
||||||
|
useSleepSettings,
|
||||||
getLastNight,
|
getLastNight,
|
||||||
hasLoggedToday,
|
hasLoggedToday,
|
||||||
getAvgDuration,
|
getAvgDuration,
|
||||||
|
|
@ -25,32 +26,15 @@
|
||||||
import MorningLog from './components/MorningLog.svelte';
|
import MorningLog from './components/MorningLog.svelte';
|
||||||
import HygieneChecklist from './components/HygieneChecklist.svelte';
|
import HygieneChecklist from './components/HygieneChecklist.svelte';
|
||||||
|
|
||||||
const entries$: Observable<SleepEntry[]> = getContext('sleepEntries');
|
const entriesQuery = useAllSleepEntries();
|
||||||
const hygieneLogs$: Observable<SleepHygieneLog[]> = getContext('sleepHygieneLogs');
|
const hygieneLogsQuery = useAllSleepHygieneLogs();
|
||||||
const hygieneChecks$: Observable<SleepHygieneCheck[]> = getContext('sleepHygieneChecks');
|
const hygieneChecksQuery = useAllSleepHygieneChecks();
|
||||||
const settings$: Observable<SleepSettings | null> = getContext('sleepSettings');
|
const settingsQuery = useSleepSettings();
|
||||||
|
|
||||||
let entries = $state<SleepEntry[]>([]);
|
let entries = $derived(entriesQuery.value);
|
||||||
let hygieneLogs = $state<SleepHygieneLog[]>([]);
|
let hygieneLogs = $derived(hygieneLogsQuery.value);
|
||||||
let hygieneChecks = $state<SleepHygieneCheck[]>([]);
|
let hygieneChecks = $derived(hygieneChecksQuery.value);
|
||||||
let settingsRaw = $state<SleepSettings | null>(null);
|
let settingsRaw = $derived(settingsQuery.value);
|
||||||
|
|
||||||
$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 settings = $derived(getEffectiveSettings(settingsRaw));
|
let settings = $derived(getEffectiveSettings(settingsRaw));
|
||||||
let lastNight = $derived(getLastNight(entries));
|
let lastNight = $derived(getLastNight(entries));
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,12 @@
|
||||||
Streak, quick-start routines, assessment recommendation, recent sessions.
|
Streak, quick-start routines, assessment recommendation, recent sessions.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import type { Observable } from 'dexie';
|
|
||||||
import type {
|
|
||||||
StretchExercise,
|
|
||||||
StretchRoutine,
|
|
||||||
StretchSession,
|
|
||||||
StretchAssessment,
|
|
||||||
StretchReminder,
|
|
||||||
} from './types';
|
|
||||||
import {
|
import {
|
||||||
|
useAllStretchExercises,
|
||||||
|
useAllStretchRoutines,
|
||||||
|
useAllStretchSessions,
|
||||||
|
useAllStretchAssessments,
|
||||||
|
useAllStretchReminders,
|
||||||
getCurrentStreak,
|
getCurrentStreak,
|
||||||
getTodayMinutes,
|
getTodayMinutes,
|
||||||
getWeekSessionCount,
|
getWeekSessionCount,
|
||||||
|
|
@ -30,38 +26,17 @@
|
||||||
import ReminderManager from './components/ReminderManager.svelte';
|
import ReminderManager from './components/ReminderManager.svelte';
|
||||||
import SessionHistory from './components/SessionHistory.svelte';
|
import SessionHistory from './components/SessionHistory.svelte';
|
||||||
|
|
||||||
const exercises$: Observable<StretchExercise[]> = getContext('stretchExercises');
|
const exercisesQuery = useAllStretchExercises();
|
||||||
const routines$: Observable<StretchRoutine[]> = getContext('stretchRoutines');
|
const routinesQuery = useAllStretchRoutines();
|
||||||
const sessions$: Observable<StretchSession[]> = getContext('stretchSessions');
|
const sessionsQuery = useAllStretchSessions();
|
||||||
const assessments$: Observable<StretchAssessment[]> = getContext('stretchAssessments');
|
const assessmentsQuery = useAllStretchAssessments();
|
||||||
const reminders$: Observable<StretchReminder[]> = getContext('stretchReminders');
|
const remindersQuery = useAllStretchReminders();
|
||||||
|
|
||||||
let exercises = $state<StretchExercise[]>([]);
|
let exercises = $derived(exercisesQuery.value);
|
||||||
let routines = $state<StretchRoutine[]>([]);
|
let routines = $derived(routinesQuery.value);
|
||||||
let sessions = $state<StretchSession[]>([]);
|
let sessions = $derived(sessionsQuery.value);
|
||||||
let assessments = $state<StretchAssessment[]>([]);
|
let assessments = $derived(assessmentsQuery.value);
|
||||||
let reminders = $state<StretchReminder[]>([]);
|
let reminders = $derived(remindersQuery.value);
|
||||||
|
|
||||||
$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 streak = $derived(getCurrentStreak(sessions));
|
let streak = $derived(getCurrentStreak(sessions));
|
||||||
let todayMinutes = $derived(Math.round(getTodayMinutes(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