feat(apps): admin panels + module settings as workbench cards

Convert 8 admin/settings subroutes into scene-droppable workbench cards
so users can arrange them alongside other modules instead of navigating
to dedicated subroutes.

Admin cards (admin-role-gated inline, fallback gate-screen for non-admins):
- admin-users: user search + paginated table
- admin-system: service-health grid + monitoring links + env info
- admin-user-data: API-backed user browser (detail route stays)
- admin-complexity: route now wraps the existing complexity card

Module-settings cards (wrap existing form components where available):
- broadcast-settings, invoices-settings: wrap SettingsForm / SenderProfileForm
- uload-settings: data-stats + JSON export + clear-local-data danger zone
- news-preferences: topics/languages/weights/onboarding reset

All 8 subroutes reduced to 10-line ListView wrappers; admin layout
keeps the role guard so the routes are still gated on direct access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 18:53:46 +02:00
parent 88eca8a759
commit 92fe23d461
16 changed files with 1742 additions and 1018 deletions

View file

@ -1165,6 +1165,36 @@ registerApp({
},
});
registerApp({
id: 'admin-users',
name: 'Admin · Users',
color: '#EF4444',
icon: AddressBook,
views: {
list: { load: () => import('$lib/modules/admin-users/ListView.svelte') },
},
});
registerApp({
id: 'admin-system',
name: 'Admin · System',
color: '#EF4444',
icon: HardDrives,
views: {
list: { load: () => import('$lib/modules/admin-system/ListView.svelte') },
},
});
registerApp({
id: 'admin-user-data',
name: 'Admin · User Data',
color: '#EF4444',
icon: File,
views: {
list: { load: () => import('$lib/modules/admin-user-data/ListView.svelte') },
},
});
registerApp({
id: 'complexity',
name: 'Complexity',
@ -1288,6 +1318,50 @@ registerApp({
},
});
// ── Module-Settings Cards ────────────────────────────
// Per-module settings/preferences as workbench cards so they can be
// dropped into any scene without a subroute.
registerApp({
id: 'broadcast-settings',
name: 'Broadcast · Settings',
color: '#6366f1',
icon: Gear,
views: {
list: { load: () => import('$lib/modules/broadcast-settings/ListView.svelte') },
},
});
registerApp({
id: 'invoices-settings',
name: 'Invoices · Settings',
color: '#059669',
icon: Gear,
views: {
list: { load: () => import('$lib/modules/invoices-settings/ListView.svelte') },
},
});
registerApp({
id: 'uload-settings',
name: 'uLoad · Settings',
color: '#0EA5E9',
icon: Gear,
views: {
list: { load: () => import('$lib/modules/uload-settings/ListView.svelte') },
},
});
registerApp({
id: 'news-preferences',
name: 'News · Preferences',
color: '#10B981',
icon: Gear,
views: {
list: { load: () => import('$lib/modules/news-preferences/ListView.svelte') },
},
});
registerApp({
id: 'quiz',
name: 'Quiz',

View file

@ -0,0 +1,349 @@
<!--
Admin → System — workbench card.
Service-health grid + quick-links to Grafana/Prometheus/Umami +
environment info. Self-hides for non-admin users.
Service list is still mock data — swap for a /api/admin/health sweep
once the endpoint exists.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { ArrowSquareOut, ShieldWarning } from '@mana/shared-icons';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
interface ServiceHealth {
name: string;
status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
url: string;
lastCheck?: string;
}
let isAdmin = $derived(authStore.user?.role === 'admin');
let services = $state<ServiceHealth[]>([]);
let loading = $state(true);
const monitoringLinks = [
{
name: 'Grafana — System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk, Network',
icon: 'grafana' as const,
},
{
name: 'Grafana — Backends & Docker',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container metrics & API performance',
icon: 'docker' as const,
},
{
name: 'Prometheus',
url: 'https://grafana.mana.how/explore',
description: 'Raw metrics & queries',
icon: 'api' as const,
},
{
name: 'Umami Analytics',
url: 'https://stats.mana.how',
description: 'Web analytics dashboard',
icon: 'analytics' as const,
},
];
const statusColors = {
healthy: 'hsl(142 71% 45%)',
degraded: 'hsl(48 96% 53%)',
unhealthy: 'hsl(0 84% 60%)',
unknown: 'hsl(0 0% 60%)',
};
const statusLabels = {
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
unknown: 'Unknown',
};
onMount(async () => {
if (!isAdmin) {
loading = false;
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
services = [
{ name: 'Mana Core Auth', status: 'healthy', url: 'https://auth.mana.how' },
{ name: 'Mana Web', status: 'healthy', url: 'https://mana.how' },
{ name: 'Mana API', status: 'healthy', url: 'https://api.mana.how' },
{ name: 'Mana Sync', status: 'healthy', url: '-' },
{ name: 'Mana AI', status: 'healthy', url: '-' },
{ name: 'Mana Research', status: 'healthy', url: '-' },
{ name: 'PostgreSQL', status: 'healthy', url: '-' },
{ name: 'Redis', status: 'healthy', url: '-' },
{ name: 'MinIO', status: 'healthy', url: 'https://s3.mana.how' },
{ name: 'Grafana', status: 'healthy', url: 'https://grafana.mana.how' },
{ name: 'Umami', status: 'healthy', url: 'https://stats.mana.how' },
];
loading = false;
});
let healthyCount = $derived(services.filter((s) => s.status === 'healthy').length);
let totalCount = $derived(services.length);
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} />
<h3>Admin-only</h3>
<p>Die System-Übersicht ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="pane">
<section class="panel">
<header class="panel-header">
<h3>System Status</h3>
{#if !loading}
<div class="status-summary">
<span
class="dot"
style:background={healthyCount === totalCount
? statusColors.healthy
: statusColors.degraded}
></span>
<span class="status-count">{healthyCount}/{totalCount} healthy</span>
</div>
{/if}
</header>
{#if loading}
<div class="grid">
{#each Array(8) as _}
<div class="skeleton"></div>
{/each}
</div>
{:else}
<div class="grid">
{#each services as service}
<div class="service">
<span class="dot" style:background={statusColors[service.status]}></span>
<div class="service-info">
<p class="service-name">{service.name}</p>
<p class="service-status">{statusLabels[service.status]}</p>
</div>
{#if service.url !== '-'}
<a href={service.url} target="_blank" rel="noopener noreferrer" class="link">
<ArrowSquareOut size={14} />
</a>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<QuickLinks links={monitoringLinks} />
<section class="panel">
<h3>Environment</h3>
<div class="env-grid">
<div class="env-col">
<div class="env-row">
<span class="env-label">Server</span>
<code>Mac Mini (mana.how)</code>
</div>
<div class="env-row">
<span class="env-label">Domain</span>
<code>*.mana.how</code>
</div>
<div class="env-row">
<span class="env-label">SSL</span>
<code class="ok">Caddy (Auto)</code>
</div>
</div>
<div class="env-col">
<div class="env-row">
<span class="env-label">Database</span>
<code>PostgreSQL 16</code>
</div>
<div class="env-row">
<span class="env-label">Cache</span>
<code>Redis 7</code>
</div>
<div class="env-row">
<span class="env-label">Tunnel</span>
<code>Cloudflare</code>
</div>
</div>
</div>
</section>
</div>
{/if}
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
color: hsl(var(--color-muted-foreground));
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
color: hsl(var(--color-foreground));
}
.admin-gate p {
font-size: 0.875rem;
max-width: 24rem;
margin: 0;
}
.pane {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.panel {
border: 1px solid hsl(var(--color-border));
border-radius: 10px;
background: hsl(var(--color-card));
padding: 1rem;
}
.panel h3 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.panel-header h3 {
margin: 0;
}
.status-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.5rem;
}
.skeleton {
height: 3rem;
background: hsl(var(--color-muted, 0 0% 95%));
border-radius: 8px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.service {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-background, var(--color-card)));
}
.service-info {
flex: 1;
min-width: 0;
}
.service-name {
font-size: 0.8125rem;
font-weight: 500;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-status {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.link {
color: hsl(var(--color-muted-foreground));
display: flex;
align-items: center;
}
.link:hover {
color: hsl(var(--color-foreground));
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.env-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.env-col {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.env-row {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
}
.env-label {
color: hsl(var(--color-muted-foreground));
}
code {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.75rem;
}
code.ok {
color: hsl(142 71% 45%);
}
</style>

View file

@ -0,0 +1,488 @@
<!--
Admin → User Data — workbench card.
Real API-backed user list (adminService.getUsers) with search and
pagination. Opens the existing detail route /admin/user-data/[userId]
when the user clicks "Daten anzeigen" — that page stays route-based
because it's an entity-detail view keyed by id.
Admin-gated inline.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { adminService, type UserListItem } from '$lib/api/services/admin';
import { MagnifyingGlass, ShieldWarning } from '@mana/shared-icons';
let isAdmin = $derived(authStore.user?.role === 'admin');
let users = $state<UserListItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout>;
let page = $state(1);
let total = $state(0);
const limit = 20;
let totalPages = $derived(Math.ceil(total / limit));
async function loadUsers() {
if (!isAdmin) return;
loading = true;
error = null;
const result = await adminService.getUsers(page, limit, searchQuery || undefined);
if (result.error) {
error = result.error;
users = [];
} else if (result.data) {
users = result.data.users;
total = result.data.total;
}
loading = false;
}
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
page = 1;
loadUsers();
}, 300);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const diffMs = Date.now() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min`;
if (diffHours < 24) return `vor ${diffHours} Std`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return formatDate(dateStr);
}
onMount(() => {
if (isAdmin) loadUsers();
else loading = false;
});
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} />
<h3>Admin-only</h3>
<p>Der Nutzerdaten-Browser ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="pane">
<header class="bar">
<div class="title">
<strong>Nutzerdaten</strong>
<span class="sub">{total} Nutzer</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input
type="text"
placeholder="Email oder Name…"
bind:value={searchQuery}
oninput={handleSearch}
/>
</div>
</header>
<div class="panel">
{#if loading}
<div class="loading">
{#each Array(5) as _}
<div class="loading-row">
<div class="avatar-skel"></div>
<div class="meta-skel">
<div class="bar-skel short"></div>
<div class="bar-skel"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="error-box">
<p>{error}</p>
<button type="button" onclick={() => loadUsers()}>Erneut versuchen</button>
</div>
{:else if users.length === 0}
<p class="empty">Keine Nutzer gefunden.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nutzer</th>
<th>Rolle</th>
<th>Registriert</th>
<th>Letzte Aktivität</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>
<div class="user-cell">
<div class="avatar">
{(user.name || user.email)[0].toUpperCase()}
</div>
<div>
<p class="user-name">{user.name || '—'}</p>
<p class="user-email">{user.email}</p>
</div>
</div>
</td>
<td>
<span class="role-badge" class:role-admin={user.role === 'admin'}>
{user.role}
</span>
</td>
<td class="muted">{formatDate(user.createdAt)}</td>
<td class="muted">{formatRelativeTime(user.lastActiveAt)}</td>
<td class="action-cell">
<button
type="button"
onclick={() => goto(`/admin/user-data/${user.id}`)}
class="view-btn"
>
Daten anzeigen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="pagination">
<button
type="button"
onclick={() => {
page = Math.max(1, page - 1);
loadUsers();
}}
disabled={page === 1}>Zurück</button
>
<span>Seite {page} von {totalPages}</span>
<button
type="button"
onclick={() => {
page = Math.min(totalPages, page + 1);
loadUsers();
}}
disabled={page === totalPages}>Weiter</button
>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
color: hsl(var(--color-muted-foreground));
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
color: hsl(var(--color-foreground));
}
.pane {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
color: hsl(var(--color-muted-foreground));
min-width: 14rem;
}
.search input {
flex: 1;
background: transparent;
border: 0;
outline: none;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
}
.panel {
border: 1px solid hsl(var(--color-border));
border-radius: 10px;
background: hsl(var(--color-card));
overflow: hidden;
}
.loading {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.loading-row {
display: flex;
align-items: center;
gap: 1rem;
}
.avatar-skel {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: hsl(var(--color-muted));
animation: pulse 1.5s ease-in-out infinite;
}
.meta-skel {
flex: 1;
}
.bar-skel {
height: 0.625rem;
background: hsl(var(--color-muted));
border-radius: 3px;
animation: pulse 1.5s ease-in-out infinite;
}
.bar-skel + .bar-skel {
margin-top: 0.5rem;
}
.bar-skel.short {
width: 25%;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.error-box {
padding: 2rem;
text-align: center;
}
.error-box p {
color: hsl(0 70% 55%);
margin: 0 0 1rem;
}
.error-box button {
padding: 0.5rem 1rem;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border: 0;
border-radius: 8px;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.empty {
padding: 2rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: hsl(var(--color-muted));
}
th {
text-align: left;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.625rem 1rem;
}
td {
padding: 0.625rem 1rem;
border-top: 1px solid hsl(var(--color-border) / 0.5);
font-size: 0.875rem;
}
tbody tr:hover {
background: hsl(var(--color-muted) / 0.3);
}
.user-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.user-name {
font-weight: 500;
margin: 0;
}
.user-email {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.role-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.02em;
}
.role-badge.role-admin {
background: hsl(0 84% 60% / 0.12);
color: hsl(0 84% 45%);
}
.muted {
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.action-cell {
text-align: right;
}
.view-btn {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
transition: background 120ms ease;
}
.view-btn:hover {
background: hsl(var(--color-muted) / 0.5);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.pagination button {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,267 @@
<!--
Admin → Users — workbench card.
User search + paginated table. Reads from the same mock dataset the
legacy /admin/users route used; swap in `adminService.getUsers` once a
dedicated role-update endpoint ships.
Admin-gated inline so the card self-hides for non-admin users in
whatever workbench scene they picked.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { MagnifyingGlass, ShieldWarning } from '@mana/shared-icons';
interface User {
id: string;
email: string;
name?: string;
createdAt: string;
lastActiveAt?: string;
role: string;
}
let isAdmin = $derived(authStore.user?.role === 'admin');
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let currentPage = $state(1);
const pageSize = 20;
let filteredUsers = $derived(
searchQuery
? users.filter(
(u) =>
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
);
let totalPages = $derived(Math.max(1, Math.ceil(filteredUsers.length / pageSize)));
let paginatedUsers = $derived(
filteredUsers.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
$effect(() => {
searchQuery;
currentPage = 1;
});
onMount(async () => {
if (!isAdmin) {
loading = false;
return;
}
try {
await new Promise((resolve) => setTimeout(resolve, 500));
users = [
{
id: '1',
email: 'admin@mana.how',
name: 'Admin User',
createdAt: '2024-01-15T10:00:00Z',
lastActiveAt: new Date().toISOString(),
role: 'admin',
},
{
id: '2',
email: 'user1@example.com',
name: 'Max Mustermann',
createdAt: '2024-06-20T14:30:00Z',
lastActiveAt: new Date(Date.now() - 3600000).toISOString(),
role: 'user',
},
{
id: '3',
email: 'user2@example.com',
name: 'Erika Musterfrau',
createdAt: '2024-09-01T08:15:00Z',
lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
{
id: '4',
email: 'user3@example.com',
createdAt: '2024-12-10T16:45:00Z',
lastActiveAt: new Date(Date.now() - 172800000).toISOString(),
role: 'user',
},
{
id: '5',
email: 'newuser@example.com',
name: 'New User',
createdAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
];
} catch {
error = 'Failed to load users';
} finally {
loading = false;
}
});
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} class="text-muted-foreground" />
<h3>Admin-only</h3>
<p>Die Nutzerverwaltung ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="pane">
<header class="bar">
<div class="title">
<strong>Users</strong>
<span class="sub">{filteredUsers.length} / {users.length}</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input type="text" placeholder="Suche…" bind:value={searchQuery} />
</div>
</header>
<UserTable users={paginatedUsers} {loading} />
{#if totalPages > 1}
<div class="pagination">
<span>Seite {currentPage} von {totalPages}</span>
<div class="buttons">
<button
type="button"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage === 1}>Zurück</button
>
<button
type="button"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}>Weiter</button
>
</div>
</div>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</div>
{/if}
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
}
.admin-gate p {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 24rem;
margin: 0;
}
.pane {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
color: hsl(var(--color-muted-foreground));
min-width: 14rem;
}
.search input {
flex: 1;
background: transparent;
border: 0;
outline: none;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.pagination .buttons {
display: flex;
gap: 0.5rem;
}
.pagination button {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error {
padding: 0.75rem 1rem;
border: 1px solid hsl(0 70% 55% / 0.3);
background: hsl(0 70% 55% / 0.08);
border-radius: 8px;
color: hsl(0 70% 55%);
font-size: 0.8125rem;
}
</style>

View file

@ -0,0 +1,40 @@
<!--
Broadcast → Settings — workbench card.
Wraps the existing SettingsForm so both the standalone route
(/broadcasts/settings) and the workbench card render the same UI.
-->
<script lang="ts">
import SettingsForm from '$lib/modules/broadcast/components/SettingsForm.svelte';
</script>
<div class="pane">
<header class="bar">
<div class="title">
<strong>Broadcast-Einstellungen</strong>
<span class="sub">Sender-Defaults, Impressum und Footer</span>
</div>
</header>
<SettingsForm />
</div>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
color: hsl(var(--color-foreground));
}
.bar {
margin-bottom: 1rem;
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,40 @@
<!--
Invoices → Settings — workbench card.
Wraps SenderProfileForm so the standalone route
(/invoices/settings) and the workbench card share the same UI.
-->
<script lang="ts">
import SenderProfileForm from '$lib/modules/invoices/components/SenderProfileForm.svelte';
</script>
<div class="pane">
<header class="bar">
<div class="title">
<strong>Rechnungs-Einstellungen</strong>
<span class="sub">Absender, Nummernkreis und Standards</span>
</div>
</header>
<SenderProfileForm />
</div>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
color: hsl(var(--color-foreground));
}
.bar {
margin-bottom: 1rem;
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,231 @@
<!--
News → Preferences — workbench card.
Topics + languages selection, source-management link, reset learned
weights, rerun onboarding.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { usePreferences } from '$lib/modules/news/queries';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { ALL_TOPICS, type Topic, type Language } from '$lib/modules/news/types';
import { TOPIC_LABELS } from '$lib/modules/news/sources-meta';
const prefs$ = usePreferences();
const prefs = $derived(prefs$.value);
let topicWeightCount = $derived(Object.keys(prefs.topicWeights).length);
let sourceWeightCount = $derived(Object.keys(prefs.sourceWeights).length);
async function toggleTopic(t: Topic) {
const next = prefs.selectedTopics.includes(t)
? prefs.selectedTopics.filter((x) => x !== t)
: [...prefs.selectedTopics, t];
await preferencesStore.setTopics(next);
}
async function toggleLang(l: Language) {
const next = prefs.preferredLanguages.includes(l)
? prefs.preferredLanguages.filter((x) => x !== l)
: [...prefs.preferredLanguages, l];
await preferencesStore.setLanguages(next);
}
async function resetWeights() {
if (!confirm('Alle gelernten Gewichtungen zurücksetzen?')) return;
await preferencesStore.resetWeights();
}
async function rerunOnboarding() {
await preferencesStore.applyWeightDiff({});
const { preferencesTable } = await import('$lib/modules/news/collections');
const { encryptRecord } = await import('$lib/data/crypto');
const { PREFERENCES_ID } = await import('$lib/modules/news/types');
const diff = { onboardingCompleted: false, updatedAt: new Date().toISOString() };
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
goto('/news');
}
</script>
<div class="pane">
<header class="bar">
<div class="title">
<strong>News-Einstellungen</strong>
<span class="sub">Themen · Sprachen · Gewichtungen</span>
</div>
</header>
<section class="card">
<h2>Themen</h2>
<p class="hint">Welche Themen sollen im Feed auftauchen?</p>
<div class="grid">
{#each ALL_TOPICS as topic}
<button
type="button"
class="pill"
class:selected={prefs.selectedTopics.includes(topic)}
onclick={() => toggleTopic(topic)}
>
<span class="emoji">{TOPIC_LABELS[topic].emoji}</span>
<span>{TOPIC_LABELS[topic].de}</span>
</button>
{/each}
</div>
</section>
<section class="card">
<h2>Sprachen</h2>
<div class="row">
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 Deutsch
</button>
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 English
</button>
</div>
</section>
<section class="card">
<h2>Quellen</h2>
<p class="hint">
Du blockst aktuell <strong>{prefs.blockedSources.length}</strong> Quellen.
</p>
<a class="btn-link" href="/news/sources">Quellen verwalten →</a>
</section>
<section class="card">
<h2>Gelernte Gewichtungen</h2>
<p class="hint">
Über Reaktionen lernt der Feed deine Vorlieben:
{topicWeightCount} Themen-Gewichte, {sourceWeightCount} Quellen-Gewichte.
</p>
<button type="button" class="btn-secondary" onclick={resetWeights}>Zurücksetzen</button>
</section>
<section class="card">
<h2>Onboarding</h2>
<p class="hint">Themen, Sprachen und Quellen neu wählen.</p>
<button type="button" class="btn-secondary" onclick={rerunOnboarding}>
Onboarding neu starten
</button>
</section>
</div>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.125rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
}
.card h2 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 10px;
background: hsl(var(--color-background, var(--color-card)));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.pill.selected {
border-color: hsl(var(--color-primary, 230 80% 55%));
background: hsl(var(--color-primary, 230 80% 55%) / 0.12);
}
.emoji {
font-size: 1rem;
}
.btn-secondary {
align-self: flex-start;
padding: 0.5rem 0.875rem;
border-radius: 8px;
background: hsl(var(--color-background, var(--color-card)));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.btn-secondary:hover {
background: hsl(var(--color-muted) / 0.5);
}
.btn-link {
align-self: flex-start;
color: hsl(var(--color-primary, 230 80% 55%));
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.btn-link:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,203 @@
<!--
uLoad → Settings — workbench card.
Data overview (counts), JSON export, and the "clear all local data"
danger zone. Uses the uLoad module's collections directly because this
is a local-data admin surface, not a feature.
-->
<script lang="ts">
import { Trash, DownloadSimple } from '@mana/shared-icons';
import { linkTable, uloadTagTable, uloadFolderTable, linkTagTable } from '$lib/modules/uload';
import { decryptRecords } from '$lib/data/crypto';
import { useAllLinks, useAllTags, useAllFolders } from '$lib/modules/uload';
import { toast } from 'svelte-sonner';
const links = useAllLinks();
const tags = useAllTags();
const folders = useAllFolders();
async function clearAllData() {
if (!confirm('Alle lokalen uLoad-Daten löschen? Dies kann nicht rückgängig gemacht werden.'))
return;
await linkTable.clear();
await uloadTagTable.clear();
await uloadFolderTable.clear();
await linkTagTable.clear();
toast.success('Alle uLoad-Daten gelöscht');
}
async function exportData() {
const rawLinks = await linkTable.toArray();
const allLinks = await decryptRecords('links', rawLinks);
const allTags = await uloadTagTable.toArray();
const allFolders = await uloadFolderTable.toArray();
const allLinkTags = await linkTagTable.toArray();
const data = {
exportedAt: new Date().toISOString(),
links: allLinks,
tags: allTags,
folders: allFolders,
linkTags: allLinkTags,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `uload-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export heruntergeladen');
}
</script>
<div class="pane">
<header class="bar">
<div class="title">
<strong>uLoad-Einstellungen</strong>
<span class="sub">Datenübersicht · Export · Gefahrenzone</span>
</div>
</header>
<section class="panel">
<h2>Daten</h2>
<div class="stats">
<div class="stat">
<p class="stat-value">{links.value?.length ?? 0}</p>
<p class="stat-label">Links</p>
</div>
<div class="stat">
<p class="stat-value">{tags.value?.length ?? 0}</p>
<p class="stat-label">Tags</p>
</div>
<div class="stat">
<p class="stat-value">{folders.value?.length ?? 0}</p>
<p class="stat-label">Ordner</p>
</div>
</div>
</section>
<section class="panel">
<h2>Daten exportieren</h2>
<p class="hint">Alle Links, Tags und Ordner als JSON-Datei herunterladen.</p>
<button type="button" class="btn" onclick={exportData}>
<DownloadSimple size={16} />
JSON exportieren
</button>
</section>
<section class="panel danger">
<h2>Gefahrenzone</h2>
<p class="hint">
Löscht alle lokalen uLoad-Daten (Links, Tags, Ordner). Synchronisierte Daten auf dem Server
bleiben erhalten.
</p>
<button type="button" class="btn danger" onclick={clearAllData}>
<Trash size={16} />
Alle Daten löschen
</button>
</section>
</div>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.panel {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
padding: 1.125rem;
}
.panel.danger {
border-color: hsl(0 70% 55% / 0.3);
background: hsl(0 70% 55% / 0.04);
}
.panel h2 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.panel.danger h2 {
color: hsl(0 70% 55%);
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0 0.875rem;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
text-align: center;
gap: 1rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-background, var(--color-card)));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.btn:hover {
background: hsl(var(--color-muted) / 0.5);
}
.btn.danger {
background: hsl(0 70% 55%);
color: white;
border-color: hsl(0 70% 45%);
}
.btn.danger:hover {
background: hsl(0 70% 50%);
}
</style>

View file

@ -1,75 +1,11 @@
<script lang="ts">
let iframeEl: HTMLIFrameElement | undefined = $state();
let loaded = $state(false);
let error = $state(false);
/**
* /admin/complexity — renders the complexity workbench-card ListView.
* Same view as the `complexity` app, kept under /admin for parity with
* the other admin sub-pages.
*/
function onLoad() {
loaded = true;
}
function onError() {
error = true;
}
function regenerateHint() {
navigator.clipboard?.writeText('pnpm audit:map');
}
import ListView from '$lib/modules/complexity/ListView.svelte';
</script>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Complexity Map</h2>
<p class="text-sm text-muted-foreground">
Interactive treemap of the entire codebase. Area = lines of code, color = git change
frequency (last 6 months).
</p>
</div>
<button
type="button"
onclick={regenerateHint}
class="text-xs px-3 py-1.5 rounded-md border bg-muted/40 hover:bg-muted transition-colors"
title="Copy regeneration command to clipboard"
>
Regenerate: <code>pnpm audit:map</code>
</button>
</div>
{#if error}
<div class="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm">
<p class="font-medium text-amber-700 dark:text-amber-300">Map not yet generated.</p>
<p class="mt-1 text-muted-foreground">
Run <code class="px-1 py-0.5 rounded bg-muted">pnpm audit:map</code> from the repo root. It
writes to
<code class="px-1 py-0.5 rounded bg-muted">static/admin/complexity-map.html</code>.
</p>
</div>
{/if}
<div
class="rounded-lg border overflow-hidden bg-background"
style="height: calc(100vh - 280px); min-height: 500px;"
>
<iframe
bind:this={iframeEl}
src="/admin/complexity-map.html"
title="Complexity Map"
class="w-full h-full border-0"
onload={onLoad}
onerror={onError}
></iframe>
</div>
<details class="text-sm rounded-md border bg-muted/20 p-3">
<summary class="cursor-pointer font-medium">Related reports</summary>
<ul class="mt-2 space-y-1 text-muted-foreground">
<li><code>docs/module-health.md</code> — per-module LOC × churn score</li>
<li><code>docs/module-coupling.md</code> — inter-module imports (fan-in / fan-out)</li>
<li><code>docs/complexity-hotspots.md</code> — top functions by cognitive complexity</li>
</ul>
<p class="mt-2 text-xs text-muted-foreground">
All reports regenerate automatically every Monday 06:00 UTC via the
<code>module-health</code> GitHub Action.
</p>
</details>
</div>
<ListView />

View file

@ -1,174 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ArrowSquareOut } from '@mana/shared-icons';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
/**
* /admin/system — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
*/
interface ServiceHealth {
name: string;
status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
url: string;
lastCheck?: string;
}
let services = $state<ServiceHealth[]>([]);
let loading = $state(true);
const monitoringLinks = [
{
name: 'Grafana - System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk, Network',
icon: 'grafana' as const,
},
{
name: 'Grafana - Backends & Docker',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container metrics & API performance',
icon: 'docker' as const,
},
{
name: 'Prometheus',
url: 'https://grafana.mana.how/explore',
description: 'Raw metrics & queries',
icon: 'api' as const,
},
{
name: 'Umami Analytics',
url: 'https://stats.mana.how',
description: 'Web analytics dashboard',
icon: 'analytics' as const,
},
];
const statusColors = {
healthy: 'bg-green-500',
degraded: 'bg-yellow-500',
unhealthy: 'bg-red-500',
unknown: 'bg-gray-400',
};
const statusLabels = {
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
unknown: 'Unknown',
};
onMount(async () => {
// TODO: Replace with actual health check API
await new Promise((resolve) => setTimeout(resolve, 500));
services = [
{ name: 'Mana Core Auth', status: 'healthy', url: 'https://auth.mana.how' },
{ name: 'Mana Web', status: 'healthy', url: 'https://mana.how' },
{ name: 'Chat Backend', status: 'healthy', url: 'https://chat-api.mana.how' },
{ name: 'Chat Web', status: 'healthy', url: 'https://chat.mana.how' },
{ name: 'Todo Backend', status: 'healthy', url: 'https://todo-api.mana.how' },
{ name: 'Todo Web', status: 'healthy', url: 'https://todo.mana.how' },
{ name: 'Calendar Backend', status: 'healthy', url: 'https://calendar-api.mana.how' },
{ name: 'Calendar Web', status: 'healthy', url: 'https://calendar.mana.how' },
{ name: 'Clock Backend', status: 'healthy', url: 'https://clock-api.mana.how' },
{ name: 'Clock Web', status: 'healthy', url: 'https://clock.mana.how' },
{ name: 'Contacts Backend', status: 'healthy', url: 'https://contacts-api.mana.how' },
{ name: 'Contacts Web', status: 'healthy', url: 'https://contacts.mana.how' },
{ name: 'PostgreSQL', status: 'healthy', url: '-' },
{ name: 'Redis', status: 'healthy', url: '-' },
{ name: 'Grafana', status: 'healthy', url: 'https://grafana.mana.how' },
{ name: 'Umami', status: 'healthy', url: 'https://stats.mana.how' },
];
loading = false;
});
let healthyCount = $derived(services.filter((s) => s.status === 'healthy').length);
let totalCount = $derived(services.length);
import ListView from '$lib/modules/admin-system/ListView.svelte';
</script>
<div class="space-y-6">
<!-- System Status Overview -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">System Status</h3>
{#if !loading}
<div class="flex items-center gap-2">
<div
class="h-2.5 w-2.5 rounded-full {healthyCount === totalCount
? 'bg-green-500'
: 'bg-yellow-500'}"
></div>
<span class="text-sm font-medium">
{healthyCount}/{totalCount} Services Healthy
</span>
</div>
{/if}
</div>
{#if loading}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each Array(8) as _}
<div class="animate-pulse h-12 bg-muted rounded-lg"></div>
{/each}
</div>
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each services as service}
<div class="flex items-center gap-3 p-3 rounded-lg border bg-background">
<div class="h-2.5 w-2.5 rounded-full {statusColors[service.status]}"></div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{service.name}</p>
<p class="text-xs text-muted-foreground">{statusLabels[service.status]}</p>
</div>
{#if service.url !== '-'}
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-foreground"
>
<ArrowSquareOut size={16} />
</a>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Monitoring Links -->
<QuickLinks links={monitoringLinks} />
<!-- Environment Info -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Environment</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Server</span>
<span class="font-mono">Mac Mini (mana.how)</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Domain</span>
<span class="font-mono">*.mana.how</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">SSL</span>
<span class="font-mono text-green-600">Caddy (Auto)</span>
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Database</span>
<span class="font-mono">PostgreSQL 16</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Cache</span>
<span class="font-mono">Redis 7</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Tunnel</span>
<span class="font-mono">Cloudflare</span>
</div>
</div>
</div>
</div>
</div>
<ListView />

View file

@ -1,246 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { adminService, type UserListItem } from '$lib/api/services/admin';
import { MagnifyingGlass } from '@mana/shared-icons';
/**
* /admin/user-data — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
* The per-user detail route /admin/user-data/[userId] stays route-based.
*/
let users = $state<UserListItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout>;
let page = $state(1);
let total = $state(0);
const limit = 20;
let totalPages = $derived(Math.ceil(total / limit));
async function loadUsers() {
loading = true;
error = null;
const result = await adminService.getUsers(page, limit, searchQuery || undefined);
if (result.error) {
error = result.error;
users = [];
} else if (result.data) {
users = result.data.users;
total = result.data.total;
}
loading = false;
}
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
page = 1;
loadUsers();
}, 300);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min`;
if (diffHours < 24) return `vor ${diffHours} Std`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return formatDate(dateStr);
}
function viewUserData(userId: string) {
goto(`/admin/user-data/${userId}`);
}
onMount(() => {
loadUsers();
});
import ListView from '$lib/modules/admin-user-data/ListView.svelte';
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Nutzerdaten</h1>
<p class="text-muted-foreground">Durchsuche und analysiere Nutzerdaten aller Projekte</p>
</div>
</div>
<!-- Search -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<MagnifyingGlass
size={20}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Nach Email oder Name suchen..."
bind:value={searchQuery}
oninput={handleSearch}
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<span class="text-sm text-muted-foreground">
{total} Nutzer gefunden
</span>
</div>
<!-- User Table -->
<div class="rounded-lg border bg-card shadow-sm overflow-hidden">
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Nutzer</h3>
</div>
{#if loading}
<div class="p-4 space-y-3">
{#each Array(5) as _}
<div class="animate-pulse flex items-center gap-4">
<div class="h-10 w-10 bg-muted rounded-full"></div>
<div class="flex-1">
<div class="h-4 bg-muted rounded w-1/4 mb-2"></div>
<div class="h-3 bg-muted rounded w-1/3"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="p-8 text-center">
<p class="text-red-500">{error}</p>
<button
onclick={() => loadUsers()}
class="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Erneut versuchen
</button>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-muted/50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Nutzer
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Rolle
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Registriert
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Letzte Aktivitat
</th>
<th
class="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Aktionen
</th>
</tr>
</thead>
<tbody class="divide-y">
{#each users as user}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
>
<span class="text-sm font-medium text-primary">
{(user.name || user.email)[0].toUpperCase()}
</span>
</div>
<div>
<p class="font-medium text-sm">{user.name || '-'}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{user.role === 'admin'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
>
{user.role}
</span>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatDate(user.createdAt)}
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatRelativeTime(user.lastActiveAt)}
</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => viewUserData(user.id)}
class="px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-md hover:bg-primary/20 transition-colors"
>
Daten anzeigen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if users.length === 0}
<div class="p-8 text-center text-muted-foreground">Keine Nutzer gefunden</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="p-4 border-t flex items-center justify-between">
<button
onclick={() => {
page = Math.max(1, page - 1);
loadUsers();
}}
disabled={page === 1}
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
>
Zuruck
</button>
<span class="text-sm text-muted-foreground">
Seite {page} von {totalPages}
</span>
<button
onclick={() => {
page = Math.min(totalPages, page + 1);
loadUsers();
}}
disabled={page === totalPages}
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
>
Weiter
</button>
</div>
{/if}
{/if}
</div>
</div>
<ListView />

View file

@ -1,156 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { MagnifyingGlass } from '@mana/shared-icons';
/**
* /admin/users — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
*/
interface User {
id: string;
email: string;
name?: string;
createdAt: string;
lastActiveAt?: string;
role: string;
}
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let currentPage = $state(1);
const pageSize = 20;
let filteredUsers = $derived(
searchQuery
? users.filter(
(u) =>
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
);
let totalPages = $derived(Math.max(1, Math.ceil(filteredUsers.length / pageSize)));
let paginatedUsers = $derived(
filteredUsers.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
// Reset to page 1 when search changes
$effect(() => {
searchQuery;
currentPage = 1;
});
onMount(async () => {
try {
// TODO: Replace with actual API call
// const response = await fetch('/api/admin/users');
// users = await response.json();
// Mock data for now
await new Promise((resolve) => setTimeout(resolve, 500));
users = [
{
id: '1',
email: 'admin@mana.how',
name: 'Admin User',
createdAt: '2024-01-15T10:00:00Z',
lastActiveAt: new Date().toISOString(),
role: 'admin',
},
{
id: '2',
email: 'user1@example.com',
name: 'Max Mustermann',
createdAt: '2024-06-20T14:30:00Z',
lastActiveAt: new Date(Date.now() - 3600000).toISOString(),
role: 'user',
},
{
id: '3',
email: 'user2@example.com',
name: 'Erika Musterfrau',
createdAt: '2024-09-01T08:15:00Z',
lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
{
id: '4',
email: 'user3@example.com',
createdAt: '2024-12-10T16:45:00Z',
lastActiveAt: new Date(Date.now() - 172800000).toISOString(),
role: 'user',
},
{
id: '5',
email: 'newuser@example.com',
name: 'New User',
createdAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
];
} catch (e) {
error = 'Failed to load users';
} finally {
loading = false;
}
});
import ListView from '$lib/modules/admin-users/ListView.svelte';
</script>
<div class="space-y-6">
<!-- Search & Filters -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<MagnifyingGlass
size={20}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<span class="text-sm text-muted-foreground">
{filteredUsers.length} of {users.length} users
</span>
</div>
<!-- User Table -->
<UserTable users={paginatedUsers} {loading} />
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between pt-4">
<span class="text-sm text-muted-foreground">
Seite {currentPage} von {totalPages}
</span>
<div class="flex gap-2">
<button
type="button"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
class="rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-40 hover:bg-muted"
>
Zuruck
</button>
<button
type="button"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
class="rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-40 hover:bg-muted"
>
Weiter
</button>
</div>
</div>
{/if}
{#if error}
<div
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4"
>
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>
<ListView />

View file

@ -1,40 +1,12 @@
<script lang="ts">
import SettingsForm from '$lib/modules/broadcast/components/SettingsForm.svelte';
/**
* /broadcasts/settings — renders the workbench-card ListView.
*/
import ListView from '$lib/modules/broadcast-settings/ListView.svelte';
</script>
<svelte:head>
<title>Broadcast-Einstellungen - Mana</title>
<title>Broadcast-Einstellungen Mana</title>
</svelte:head>
<div class="page">
<header class="head">
<h1>Broadcast-Einstellungen</h1>
<p class="subtitle">Sender-Defaults, Impressum und Footer für alle neuen Kampagnen.</p>
</header>
<SettingsForm />
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.head {
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
</style>
<ListView />

View file

@ -1,40 +1,12 @@
<script lang="ts">
import SenderProfileForm from '$lib/modules/invoices/components/SenderProfileForm.svelte';
/**
* /invoices/settings — renders the workbench-card ListView.
*/
import ListView from '$lib/modules/invoices-settings/ListView.svelte';
</script>
<svelte:head>
<title>Rechnungs-Einstellungen - Mana</title>
<title>Rechnungs-Einstellungen Mana</title>
</svelte:head>
<div class="page">
<header class="head">
<h1>Rechnungs-Einstellungen</h1>
<p class="subtitle">Absender, Nummernkreis und Standards für alle neuen Rechnungen.</p>
</header>
<SenderProfileForm />
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.head {
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
</style>
<ListView />

View file

@ -1,217 +1,12 @@
<!--
/news/preferences — adjust topics, languages, and reset learned weights.
Source-level blocking lives at /news/sources. Onboarding can be
re-run from here too.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { usePreferences } from '$lib/modules/news/queries';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { ALL_TOPICS, type Topic, type Language } from '$lib/modules/news/types';
import { TOPIC_LABELS } from '$lib/modules/news/sources-meta';
const prefs$ = usePreferences();
const prefs = $derived(prefs$.value);
let topicWeightCount = $derived(Object.keys(prefs.topicWeights).length);
let sourceWeightCount = $derived(Object.keys(prefs.sourceWeights).length);
async function toggleTopic(t: Topic) {
const next = prefs.selectedTopics.includes(t)
? prefs.selectedTopics.filter((x) => x !== t)
: [...prefs.selectedTopics, t];
await preferencesStore.setTopics(next);
}
async function toggleLang(l: Language) {
const next = prefs.preferredLanguages.includes(l)
? prefs.preferredLanguages.filter((x) => x !== l)
: [...prefs.preferredLanguages, l];
await preferencesStore.setLanguages(next);
}
async function resetWeights() {
if (!confirm('Alle gelernten Gewichtungen zurücksetzen?')) return;
await preferencesStore.resetWeights();
}
async function rerunOnboarding() {
// Flip the flag back so the main +page.svelte renders the wizard.
await preferencesStore.applyWeightDiff({});
// Need a separate setter — re-using completeOnboarding inverted.
const { preferencesTable } = await import('$lib/modules/news/collections');
const { encryptRecord } = await import('$lib/data/crypto');
const { PREFERENCES_ID } = await import('$lib/modules/news/types');
const diff = { onboardingCompleted: false, updatedAt: new Date().toISOString() };
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
goto('/news');
}
/**
* /news/preferences — renders the workbench-card ListView.
*/
import ListView from '$lib/modules/news-preferences/ListView.svelte';
</script>
<svelte:head>
<title>Einstellungen — News — Mana</title>
<title>News-Einstellungen — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/news')}> Feed</button>
<h1>News-Einstellungen</h1>
</header>
<section class="card">
<h2>Themen</h2>
<p class="hint">Welche Themen sollen im Feed auftauchen?</p>
<div class="grid">
{#each ALL_TOPICS as topic}
<button
type="button"
class="pill"
class:selected={prefs.selectedTopics.includes(topic)}
onclick={() => toggleTopic(topic)}
>
<span class="emoji">{TOPIC_LABELS[topic].emoji}</span>
<span>{TOPIC_LABELS[topic].de}</span>
</button>
{/each}
</div>
</section>
<section class="card">
<h2>Sprachen</h2>
<div class="row">
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 Deutsch
</button>
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 English
</button>
</div>
</section>
<section class="card">
<h2>Quellen</h2>
<p class="hint">
Du blockst aktuell <strong>{prefs.blockedSources.length}</strong> Quellen.
</p>
<a class="btn-link" href="/news/sources">Quellen verwalten →</a>
</section>
<section class="card">
<h2>Gelernte Gewichtungen</h2>
<p class="hint">
Über Reaktionen lernt der Feed deine Vorlieben:
{topicWeightCount} Themen-Gewichte, {sourceWeightCount} Quellen-Gewichte.
</p>
<button type="button" class="btn-secondary" onclick={resetWeights}> Zurücksetzen </button>
</section>
<section class="card">
<h2>Onboarding</h2>
<p class="hint">Themen, Sprachen und Quellen neu wählen.</p>
<button type="button" class="btn-secondary" onclick={rerunOnboarding}>
Onboarding neu starten
</button>
</section>
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
}
.card h2 {
font-size: 1.0625rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
border-radius: 0.625rem;
background: hsl(var(--color-background));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
}
.pill.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.12);
}
.emoji {
font-size: 1.125rem;
}
.btn-secondary {
align-self: flex-start;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.btn-link {
align-self: flex-start;
color: hsl(var(--color-primary));
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.btn-link:hover {
text-decoration: underline;
}
</style>
<ListView />

View file

@ -1,110 +1,12 @@
<script lang="ts">
import { ArrowLeft, Trash, DownloadSimple } from '@mana/shared-icons';
import { linkTable, uloadTagTable, uloadFolderTable, linkTagTable } from '$lib/modules/uload';
import { decryptRecords } from '$lib/data/crypto';
import { useAllLinks, useAllTags, useAllFolders } from '$lib/modules/uload';
import { toast } from 'svelte-sonner';
const links = useAllLinks();
const tags = useAllTags();
const folders = useAllFolders();
async function clearAllData() {
if (!confirm('Alle lokalen uLoad-Daten loeschen? Dies kann nicht rueckgaengig gemacht werden.'))
return;
await linkTable.clear();
await uloadTagTable.clear();
await uloadFolderTable.clear();
await linkTagTable.clear();
toast.success('Alle uLoad-Daten geloescht');
}
async function exportData() {
// Decrypt links before serializing the export — otherwise the user
// would download a JSON of ciphertext blobs they couldn't restore.
const rawLinks = await linkTable.toArray();
const allLinks = await decryptRecords('links', rawLinks);
const allTags = await uloadTagTable.toArray();
const allFolders = await uloadFolderTable.toArray();
const allLinkTags = await linkTagTable.toArray();
const data = {
exportedAt: new Date().toISOString(),
links: allLinks,
tags: allTags,
folders: allFolders,
linkTags: allLinkTags,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `uload-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export heruntergeladen');
}
/**
* /uload/settings — renders the workbench-card ListView.
*/
import ListView from '$lib/modules/uload-settings/ListView.svelte';
</script>
<svelte:head>
<title>Settings - uLoad - Mana</title>
<title>uLoad-Einstellungen — Mana</title>
</svelte:head>
<div class="mx-auto max-w-2xl p-4">
<div class="mb-6 flex items-center gap-3">
<a href="/uload" class="rounded-lg p-2 transition-colors hover:bg-white/5">
<ArrowLeft size={20} class="text-white/60" />
</a>
<h1 class="text-2xl font-bold text-white">uLoad Einstellungen</h1>
</div>
<!-- Data Overview -->
<div class="mb-6 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Daten</h2>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-2xl font-bold text-white">{links.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Links</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{tags.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Tags</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{folders.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Ordner</p>
</div>
</div>
</div>
<!-- Export -->
<div class="mb-4 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-2 text-lg font-semibold text-white">Daten exportieren</h2>
<p class="mb-4 text-sm text-white/40">Exportiere alle Links, Tags und Ordner als JSON-Datei.</p>
<button
onclick={exportData}
class="flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15"
>
<DownloadSimple size={18} />
JSON exportieren
</button>
</div>
<!-- Danger Zone -->
<div class="rounded-xl border border-red-500/20 bg-red-500/5 p-6">
<h2 class="mb-2 text-lg font-semibold text-red-400">Gefahrenzone</h2>
<p class="mb-4 text-sm text-white/40">
Loescht alle lokalen uLoad-Daten (Links, Tags, Ordner). Synchronisierte Daten auf dem Server
bleiben erhalten.
</p>
<button
onclick={clearAllData}
class="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
>
<Trash size={18} />
Alle Daten loeschen
</button>
</div>
</div>
<ListView />