mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
refactor(admin): fuse admin-* cards into one tabbed admin card
User feedback: four separate admin cards (admin-users, admin-system,
admin-user-data + admin) bloated the scene-picker without adding value
— they're one logical power-user surface split four ways. Fuse them
into a single admin card with an internal tab switcher.
- lib/modules/admin/tabs/{Overview,Users,System,UserData}Tab.svelte —
each tab owns its own data + styles
- lib/modules/admin/ListView.svelte is now a tabbed container: one
role-guard, one pill-row, deep-linkable via `initialTab` prop
- /admin, /admin/users, /admin/system, /admin/user-data routes pass
the corresponding initialTab so direct URLs still land on the right
section
- Delete lib/modules/admin-{users,system,user-data}/ + three
registerApp entries
- Complexity stays a separate card (different shape — iframe-heavy,
was already its own card before this batch)
Smoketest: all 5 /admin/* routes respond 200; type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e65637fcb
commit
43b4570e69
10 changed files with 597 additions and 641 deletions
|
|
@ -1165,36 +1165,6 @@ 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',
|
||||
|
|
|
|||
|
|
@ -1,264 +1,150 @@
|
|||
<!--
|
||||
Admin — Workbench-embedded admin dashboard with stats, security overview,
|
||||
and quick links to monitoring tools.
|
||||
Admin — workbench card with tabs.
|
||||
|
||||
One card, four tabs: Overview / Users / System / User Data. Having all
|
||||
admin surfaces under one id keeps the scene-picker uncluttered while
|
||||
letting power-users arrange them inside the workbench like any other
|
||||
card. The `initialTab` prop lets the /admin/* route wrappers deep-link
|
||||
directly to a tab.
|
||||
|
||||
Admin-role guard lives here at the container level so a non-admin
|
||||
sees one gate-screen, not four.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import StatCard from '$lib/components/admin/StatCard.svelte';
|
||||
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
|
||||
import { adminService, type AdminStats } from '$lib/api/services/admin';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ShieldWarning } from '@mana/shared-icons';
|
||||
import OverviewTab from './tabs/OverviewTab.svelte';
|
||||
import UsersTab from './tabs/UsersTab.svelte';
|
||||
import SystemTab from './tabs/SystemTab.svelte';
|
||||
import UserDataTab from './tabs/UserDataTab.svelte';
|
||||
|
||||
let stats = $state<AdminStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
type TabId = 'overview' | 'users' | 'system' | 'user-data';
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
name: 'Grafana Dashboard',
|
||||
url: 'https://grafana.mana.how',
|
||||
description: 'System & Backend Metrics',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
{
|
||||
name: 'Umami Analytics',
|
||||
url: 'https://stats.mana.how',
|
||||
description: 'Web Analytics',
|
||||
icon: 'analytics' as const,
|
||||
},
|
||||
{
|
||||
name: 'Docker Dashboard',
|
||||
url: 'https://grafana.mana.how/d/backends-docker',
|
||||
description: 'Container Metrics',
|
||||
icon: 'docker' as const,
|
||||
},
|
||||
{
|
||||
name: 'System Overview',
|
||||
url: 'https://grafana.mana.how/d/system-overview',
|
||||
description: 'CPU, Memory, Disk',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
interface Props {
|
||||
initialTab?: TabId;
|
||||
}
|
||||
|
||||
let { initialTab = 'overview' }: Props = $props();
|
||||
|
||||
let activeTab = $state<TabId>(initialTab);
|
||||
let isAdmin = $derived(authStore.user?.role === 'admin');
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'user-data', label: 'User Data' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
const result = await adminService.getStats();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else {
|
||||
stats = result.data;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
let userGrowthPercent = $derived(
|
||||
stats
|
||||
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="admin-page">
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
|
||||
<StatCard
|
||||
title="New Users (7d)"
|
||||
value={stats?.newUsers7d ?? '-'}
|
||||
change={userGrowthPercent}
|
||||
changeLabel="vs previous"
|
||||
icon="users"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Sessions"
|
||||
value={stats?.activeSessions ?? '-'}
|
||||
icon="activity"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unique Users (24h)"
|
||||
value={stats?.uniqueUsers24h ?? '-'}
|
||||
icon="clock"
|
||||
{loading}
|
||||
/>
|
||||
{#if !isAdmin}
|
||||
<div class="admin-gate">
|
||||
<ShieldWarning size={40} />
|
||||
<h3>Admin-only</h3>
|
||||
<p>Das Admin-Dashboard ist nur für Admin-Nutzer sichtbar.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="admin-card">
|
||||
<div class="tabs" role="tablist" aria-label="Admin sections">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:active={activeTab === tab.id}
|
||||
aria-selected={activeTab === tab.id}
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Security & Quick Links -->
|
||||
<div class="panels">
|
||||
<!-- Security Overview -->
|
||||
<div class="panel">
|
||||
<h3 class="panel-title">Security (Last 7 Days)</h3>
|
||||
{#if loading}
|
||||
<div class="loading-rows">
|
||||
<div class="loading-bar"></div>
|
||||
<div class="loading-bar short"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<div class="security-rows">
|
||||
<div class="security-row">
|
||||
<div class="security-label">
|
||||
<span class="dot green"></span>
|
||||
<span>Successful Logins</span>
|
||||
</div>
|
||||
<span class="security-value">{stats.loginSuccess7d}</span>
|
||||
</div>
|
||||
<div class="security-row">
|
||||
<div class="security-label">
|
||||
<span class="dot red"></span>
|
||||
<span>Failed Logins</span>
|
||||
</div>
|
||||
<span class="security-value">{stats.loginFailed7d}</span>
|
||||
</div>
|
||||
<div class="security-divider"></div>
|
||||
<div class="security-row">
|
||||
<span class="security-muted">Success Rate</span>
|
||||
<span class="security-rate">
|
||||
{stats.loginSuccess7d + stats.loginFailed7d > 0
|
||||
? Math.round(
|
||||
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
|
||||
)
|
||||
: '—'}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
{#if activeTab === 'overview'}
|
||||
<OverviewTab />
|
||||
{:else if activeTab === 'users'}
|
||||
<UsersTab />
|
||||
{:else if activeTab === 'system'}
|
||||
<SystemTab />
|
||||
{:else if activeTab === 'user-data'}
|
||||
<UserDataTab />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<QuickLinks links={quickLinks} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-box">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.admin-page {
|
||||
padding: 0.75rem;
|
||||
.admin-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 0.875rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
height: 0.875rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: 0.25rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-bar.short {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.security-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.security-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.green {
|
||||
background: hsl(142 71% 45%);
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.security-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.security-divider {
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
|
||||
.security-muted {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.security-rate {
|
||||
font-size: 0.8125rem;
|
||||
.admin-gate h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: hsl(142 71% 45%);
|
||||
margin: 0;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(0 84% 60% / 0.08);
|
||||
.admin-gate p {
|
||||
font-size: 0.875rem;
|
||||
max-width: 24rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-box p {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(0 84% 60%);
|
||||
.admin-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
padding-bottom: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
239
apps/mana/apps/web/src/lib/modules/admin/tabs/OverviewTab.svelte
Normal file
239
apps/mana/apps/web/src/lib/modules/admin/tabs/OverviewTab.svelte
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<!--
|
||||
Admin → Overview tab.
|
||||
Stats grid + security (last 7d) + monitoring quick-links.
|
||||
Extracted from the former admin/ListView.svelte so it can render as
|
||||
one of four tabs alongside Users, System, User Data.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import StatCard from '$lib/components/admin/StatCard.svelte';
|
||||
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
|
||||
import { adminService, type AdminStats } from '$lib/api/services/admin';
|
||||
|
||||
let stats = $state<AdminStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
name: 'Grafana Dashboard',
|
||||
url: 'https://grafana.mana.how',
|
||||
description: 'System & Backend Metrics',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
{
|
||||
name: 'Umami Analytics',
|
||||
url: 'https://stats.mana.how',
|
||||
description: 'Web Analytics',
|
||||
icon: 'analytics' as const,
|
||||
},
|
||||
{
|
||||
name: 'Docker Dashboard',
|
||||
url: 'https://grafana.mana.how/d/backends-docker',
|
||||
description: 'Container Metrics',
|
||||
icon: 'docker' as const,
|
||||
},
|
||||
{
|
||||
name: 'System Overview',
|
||||
url: 'https://grafana.mana.how/d/system-overview',
|
||||
description: 'CPU, Memory, Disk',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
const result = await adminService.getStats();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else {
|
||||
stats = result.data;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
let userGrowthPercent = $derived(
|
||||
stats
|
||||
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="overview">
|
||||
<div class="stats-grid">
|
||||
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
|
||||
<StatCard
|
||||
title="New Users (7d)"
|
||||
value={stats?.newUsers7d ?? '-'}
|
||||
change={userGrowthPercent}
|
||||
changeLabel="vs previous"
|
||||
icon="users"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Sessions"
|
||||
value={stats?.activeSessions ?? '-'}
|
||||
icon="activity"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unique Users (24h)"
|
||||
value={stats?.uniqueUsers24h ?? '-'}
|
||||
icon="clock"
|
||||
{loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panels">
|
||||
<div class="panel">
|
||||
<h3 class="panel-title">Security (Last 7 Days)</h3>
|
||||
{#if loading}
|
||||
<div class="loading-rows">
|
||||
<div class="loading-bar"></div>
|
||||
<div class="loading-bar short"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<div class="security-rows">
|
||||
<div class="security-row">
|
||||
<div class="security-label">
|
||||
<span class="dot green"></span>
|
||||
<span>Successful Logins</span>
|
||||
</div>
|
||||
<span class="security-value">{stats.loginSuccess7d}</span>
|
||||
</div>
|
||||
<div class="security-row">
|
||||
<div class="security-label">
|
||||
<span class="dot red"></span>
|
||||
<span>Failed Logins</span>
|
||||
</div>
|
||||
<span class="security-value">{stats.loginFailed7d}</span>
|
||||
</div>
|
||||
<div class="security-divider"></div>
|
||||
<div class="security-row">
|
||||
<span class="security-muted">Success Rate</span>
|
||||
<span class="security-rate">
|
||||
{stats.loginSuccess7d + stats.loginFailed7d > 0
|
||||
? Math.round(
|
||||
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
|
||||
)
|
||||
: '—'}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<QuickLinks links={quickLinks} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-box"><p>{error}</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.panel {
|
||||
padding: 0.875rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.loading-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.loading-bar {
|
||||
height: 0.875rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: 0.25rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.loading-bar.short {
|
||||
width: 75%;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.security-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.security-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.security-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.green {
|
||||
background: hsl(142 71% 45%);
|
||||
}
|
||||
.dot.red {
|
||||
background: hsl(0 84% 60%);
|
||||
}
|
||||
.security-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.security-divider {
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
.security-muted {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.security-rate {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(142 71% 45%);
|
||||
}
|
||||
.error-box {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(0 84% 60% / 0.08);
|
||||
}
|
||||
.error-box p {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,16 +1,10 @@
|
|||
<!--
|
||||
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.
|
||||
Admin → System tab.
|
||||
Service-health grid + monitoring quick-links + environment info.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ArrowSquareOut, ShieldWarning } from '@mana/shared-icons';
|
||||
import { ArrowSquareOut } from '@mana/shared-icons';
|
||||
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
|
||||
|
||||
interface ServiceHealth {
|
||||
|
|
@ -20,7 +14,6 @@
|
|||
lastCheck?: string;
|
||||
}
|
||||
|
||||
let isAdmin = $derived(authStore.user?.role === 'admin');
|
||||
let services = $state<ServiceHealth[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
|
|
@ -66,10 +59,6 @@
|
|||
};
|
||||
|
||||
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' },
|
||||
|
|
@ -91,126 +80,91 @@
|
|||
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 class="system-tab">
|
||||
<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}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<QuickLinks links={monitoringLinks} />
|
||||
{#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>
|
||||
|
||||
<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>
|
||||
<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-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 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>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
<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;
|
||||
.system-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.panel {
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
<!--
|
||||
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.
|
||||
Admin → User Data tab.
|
||||
Real API-backed user browser (adminService.getUsers). The per-user
|
||||
detail route /admin/user-data/[userId] stays route-based.
|
||||
-->
|
||||
<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');
|
||||
import { MagnifyingGlass } from '@mana/shared-icons';
|
||||
|
||||
let users = $state<UserListItem[]>([]);
|
||||
let loading = $state(true);
|
||||
|
|
@ -29,7 +21,6 @@
|
|||
let totalPages = $derived(Math.ceil(total / limit));
|
||||
|
||||
async function loadUsers() {
|
||||
if (!isAdmin) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
|
|
@ -78,155 +69,125 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isAdmin) loadUsers();
|
||||
else loading = false;
|
||||
loadUsers();
|
||||
});
|
||||
</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 class="user-data-tab">
|
||||
<div 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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
|
||||
<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;
|
||||
.user-data-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.bar {
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
<!--
|
||||
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.
|
||||
Admin → Users tab.
|
||||
Mock user search + paginated table (swap in adminService.getUsers once
|
||||
a dedicated role-update endpoint ships).
|
||||
-->
|
||||
<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';
|
||||
import { MagnifyingGlass } from '@mana/shared-icons';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
|
|
@ -23,8 +17,6 @@
|
|||
role: string;
|
||||
}
|
||||
|
||||
let isAdmin = $derived(authStore.user?.role === 'admin');
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -53,10 +45,6 @@
|
|||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!isAdmin) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
users = [
|
||||
|
|
@ -107,82 +95,48 @@
|
|||
});
|
||||
</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 class="users-tab">
|
||||
<div 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>
|
||||
</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} />
|
||||
<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>
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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;
|
||||
.users-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.bar {
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /admin — renders the workbench-card ListView.
|
||||
* Admin-role guard lives in the parent layout and inside the ListView.
|
||||
* /admin — renders the admin workbench card with the Overview tab.
|
||||
*/
|
||||
|
||||
import ListView from '$lib/modules/admin/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
<ListView initialTab="overview" />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /admin/system — renders the workbench-card ListView.
|
||||
* Admin-role guard lives in the parent layout and inside the ListView.
|
||||
* /admin/system — deep-links into the System tab of the admin card.
|
||||
*/
|
||||
|
||||
import ListView from '$lib/modules/admin-system/ListView.svelte';
|
||||
import ListView from '$lib/modules/admin/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
<ListView initialTab="system" />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /admin/user-data — renders the workbench-card ListView.
|
||||
* Admin-role guard lives in the parent layout and inside the ListView.
|
||||
* /admin/user-data — deep-links into the User Data tab of the admin card.
|
||||
* The per-user detail route /admin/user-data/[userId] stays route-based.
|
||||
*/
|
||||
|
||||
import ListView from '$lib/modules/admin-user-data/ListView.svelte';
|
||||
import ListView from '$lib/modules/admin/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
<ListView initialTab="user-data" />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /admin/users — renders the workbench-card ListView.
|
||||
* Admin-role guard lives in the parent layout and inside the ListView.
|
||||
* /admin/users — deep-links into the Users tab of the admin card.
|
||||
*/
|
||||
|
||||
import ListView from '$lib/modules/admin-users/ListView.svelte';
|
||||
import ListView from '$lib/modules/admin/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
<ListView initialTab="users" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue