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:
266
frontend/src/routes/admin/mangas/+page.svelte
Normal file
266
frontend/src/routes/admin/mangas/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user