feat(frontend): /admin dashboard with users/mangas/system views (0.41.0)

Adds the SvelteKit /admin route tree backed by the admin endpoints
landed in PR 1-4. Pages: Overview (alerts + summary cards), Users
(list / promote-demote / delete), Mangas (list with sync state +
expandable per-chapter state), System (live disk/mem/cpu bars,
refreshing every 5s).

Security model: the backend's RequireAdmin extractor is the actual
boundary. /admin/+layout.ts calls getSystemStats() at load and
translates the response — 401 → redirect to /login, 403 → throw
SvelteKit error(403) which renders the framework error page. The
header's "Admin" link is hidden unless `session.user?.is_admin`,
but that's UX only.

Carries `is_admin: boolean` through to the frontend User TS type so
the header check works and so admin tables can show role per row.

Vitest covers lib/api/admin.ts (10 tests: list/delete/PATCH for
users, sync-state filter for mangas, nested chapter route, system
disk-nullable case). Playwright is intentionally deferred until the
routes stabilise — admin UI is operator-only and changes shape often
in v0.
This commit is contained in:
MechaCat02
2026-05-30 21:49:39 +02:00
parent cc4ec76d17
commit b434c9b68d
13 changed files with 1206 additions and 3 deletions

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listAdminMangas,
listAdminChapters,
type AdminMangasPage,
type AdminChapterRow,
type MangaSyncState
} from '$lib/api/admin';
import { ApiError } from '$lib/api/client';
let mangasPage: AdminMangasPage | null = $state(null);
let search = $state('');
let syncFilter: MangaSyncState | '' = $state('');
let error: string | null = $state(null);
let expandedId: string | null = $state(null);
let chaptersByManga: Record<string, AdminChapterRow[] | 'loading'> = $state({});
async function load() {
error = null;
try {
mangasPage = await listAdminMangas({
search: search.trim() || undefined,
syncState: syncFilter || undefined,
limit: 100
});
} catch (e) {
error = e instanceof ApiError ? e.message : 'load failed';
}
}
onMount(load);
async function toggleChapters(id: string) {
if (expandedId === id) {
expandedId = null;
return;
}
expandedId = id;
if (!chaptersByManga[id]) {
chaptersByManga[id] = 'loading';
try {
chaptersByManga[id] = await listAdminChapters(id);
} catch {
delete chaptersByManga[id];
error = 'failed to load chapters';
}
}
}
function badgeClass(state: string): string {
return `badge badge-${state}`;
}
</script>
<h1>Mangas</h1>
<form
onsubmit={(e) => {
e.preventDefault();
load();
}}
>
<input
type="search"
placeholder="search by title"
bind:value={search}
data-testid="admin-mangas-search"
/>
<select bind:value={syncFilter} aria-label="sync state">
<option value="">all states</option>
<option value="in_progress">in progress</option>
<option value="dropped">dropped</option>
<option value="synced">synced</option>
</select>
<button type="submit">Search</button>
</form>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if mangasPage}
<p class="total">{mangasPage.page.total ?? mangasPage.items.length} mangas</p>
<table>
<thead>
<tr>
<th>Title</th>
<th>Sync</th>
<th>Chapters</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{#each mangasPage.items as m (m.id)}
<tr>
<td>
<button class="link" onclick={() => toggleChapters(m.id)}>
{expandedId === m.id ? '▼' : '▶'} {m.title}
</button>
</td>
<td><span class={badgeClass(m.sync_state)}>{m.sync_state}</span></td>
<td>{m.chapter_count}</td>
<td>
{m.latest_seen_at
? new Date(m.latest_seen_at).toLocaleDateString()
: '—'}
</td>
</tr>
{#if expandedId === m.id}
<tr class="chapter-row">
<td colspan="4">
{#if chaptersByManga[m.id] === 'loading'}
<p>Loading chapters…</p>
{:else if chaptersByManga[m.id]}
{@const list = chaptersByManga[m.id] as AdminChapterRow[]}
{#if list.length === 0}
<p class="muted">No chapters.</p>
{:else}
<table class="inner">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Pages</th>
<th>Sync</th>
</tr>
</thead>
<tbody>
{#each list as c (c.id)}
<tr>
<td>{c.number}</td>
<td>{c.title ?? '—'}</td>
<td>{c.page_count}</td>
<td>
<span class={badgeClass(c.sync_state)}>
{c.sync_state}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{:else}
<p>Loading…</p>
{/if}
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
form {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
button {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button.link {
background: none;
border: none;
padding: 0;
color: var(--text);
cursor: pointer;
font-weight: inherit;
}
button.link:hover {
color: var(--primary);
}
.total {
color: var(--text-muted);
font-size: var(--font-sm);
margin: 0 0 var(--space-2) 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--space-2);
text-align: left;
border-bottom: 1px solid var(--border);
}
.chapter-row td {
background: var(--surface-elevated);
}
table.inner {
margin: var(--space-2) 0;
}
.badge {
display: inline-block;
padding: 0 var(--space-2);
border-radius: var(--radius-sm, 4px);
font-size: var(--font-xs, 0.75rem);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid var(--border);
background: var(--surface);
}
.badge-in_progress,
.badge-downloading {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.badge-dropped {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.badge-failed {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.badge-not_downloaded {
background: var(--surface-elevated);
color: var(--text-muted);
}
.badge-synced {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.muted {
color: var(--text-muted);
}
.error {
color: var(--danger, #dc2626);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger, #dc2626);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
</style>