mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
88eca8a759
commit
92fe23d461
16 changed files with 1742 additions and 1018 deletions
|
|
@ -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',
|
||||
|
|
|
|||
349
apps/mana/apps/web/src/lib/modules/admin-system/ListView.svelte
Normal file
349
apps/mana/apps/web/src/lib/modules/admin-system/ListView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
267
apps/mana/apps/web/src/lib/modules/admin-users/ListView.svelte
Normal file
267
apps/mana/apps/web/src/lib/modules/admin-users/ListView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue