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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.40.0",
|
"version": "0.41.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
188
frontend/src/lib/api/admin.test.ts
Normal file
188
frontend/src/lib/api/admin.test.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type MockInstance
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
listAdminUsers,
|
||||||
|
deleteAdminUser,
|
||||||
|
setUserAdmin,
|
||||||
|
listAdminMangas,
|
||||||
|
listAdminChapters,
|
||||||
|
getSystemStats
|
||||||
|
} from './admin';
|
||||||
|
|
||||||
|
function ok(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function noContent(): Response {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(status: number, code: string, message: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userFixture = {
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'alice',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const mangaFixture = {
|
||||||
|
id: 'm-1',
|
||||||
|
title: 'Test',
|
||||||
|
status: 'ongoing',
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
sync_state: 'synced' as const,
|
||||||
|
chapter_count: 3,
|
||||||
|
latest_seen_at: '2026-01-02T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemFixture = {
|
||||||
|
disk: {
|
||||||
|
total_bytes: 1_000_000,
|
||||||
|
used_bytes: 500_000,
|
||||||
|
free_bytes: 500_000,
|
||||||
|
percent_used: 50.0
|
||||||
|
},
|
||||||
|
memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 },
|
||||||
|
cpu: { percent_used: 12.3 },
|
||||||
|
alerts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('admin api client', () => {
|
||||||
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- users ----
|
||||||
|
|
||||||
|
it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } })
|
||||||
|
);
|
||||||
|
const page = await listAdminUsers({ limit: 50 });
|
||||||
|
expect(page.items).toHaveLength(1);
|
||||||
|
expect(page.items[0]).toEqual(userFixture);
|
||||||
|
expect(page.page.total).toBe(1);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listAdminUsers forwards search + offset query params', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [], page: { limit: 50, offset: 10, total: 0 } })
|
||||||
|
);
|
||||||
|
await listAdminUsers({ search: 'al', offset: 10 });
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('search=al');
|
||||||
|
expect(url).toContain('offset=10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden'));
|
||||||
|
await expect(listAdminUsers()).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: 'forbidden'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(noContent());
|
||||||
|
await expect(deleteAdminUser('u-1')).resolves.toBeUndefined();
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/users\/u-1$/);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
envelope(409, 'conflict', 'cannot delete yourself; ask another admin')
|
||||||
|
);
|
||||||
|
await expect(deleteAdminUser('u-1')).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'conflict'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
||||||
|
const updated = { ...userFixture, is_admin: true };
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(updated));
|
||||||
|
const got = await setUserAdmin('u-1', true);
|
||||||
|
expect(got).toEqual(updated);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('PATCH');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ is_admin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- mangas + chapters ----
|
||||||
|
|
||||||
|
it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } })
|
||||||
|
);
|
||||||
|
const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 });
|
||||||
|
expect(page.items[0].sync_state).toBe('synced');
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('sync_state=in_progress');
|
||||||
|
expect(url).toContain('limit=100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listAdminChapters GETs the nested chapter route', async () => {
|
||||||
|
const chapter = {
|
||||||
|
id: 'c-1',
|
||||||
|
manga_id: 'm-1',
|
||||||
|
number: 1,
|
||||||
|
title: null,
|
||||||
|
page_count: 12,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
sync_state: 'synced' as const,
|
||||||
|
latest_seen_at: null
|
||||||
|
};
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok([chapter]));
|
||||||
|
const rows = await listAdminChapters('m-1');
|
||||||
|
expect(rows).toEqual([chapter]);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- system ----
|
||||||
|
|
||||||
|
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(systemFixture));
|
||||||
|
const s = await getSystemStats();
|
||||||
|
expect(s.disk?.percent_used).toBe(50);
|
||||||
|
expect(s.memory.percent_used).toBe(50);
|
||||||
|
expect(s.cpu.percent_used).toBe(12.3);
|
||||||
|
expect(s.alerts).toEqual([]);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/system$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSystemStats keeps disk null when backend reports a non-local store', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null }));
|
||||||
|
const s = await getSystemStats();
|
||||||
|
expect(s.disk).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
144
frontend/src/lib/api/admin.ts
Normal file
144
frontend/src/lib/api/admin.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Admin-only API client. Every endpoint here is guarded by
|
||||||
|
// RequireAdmin on the backend (session cookie only — bearer tokens
|
||||||
|
// won't reach these routes). 403s thrown here propagate up to the
|
||||||
|
// /admin layout, which renders the framework error page.
|
||||||
|
|
||||||
|
import { request, type Page } from './client';
|
||||||
|
import type { User } from './auth';
|
||||||
|
|
||||||
|
// ---- users -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AdminUsersPage = {
|
||||||
|
items: User[];
|
||||||
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAdminUsersOptions = {
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAdminUsers(
|
||||||
|
opts: ListAdminUsersOptions = {}
|
||||||
|
): Promise<AdminUsersPage> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.search) params.set('search', opts.search);
|
||||||
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<AdminUsersPage>(`/v1/admin/users${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(id: string): Promise<void> {
|
||||||
|
await request<void>(`/v1/admin/users/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User> {
|
||||||
|
return request<User>(`/v1/admin/users/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_admin: isAdmin })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mangas / chapters with sync state -------------------------------------
|
||||||
|
|
||||||
|
export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';
|
||||||
|
|
||||||
|
export type AdminMangaRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
cover_image_path: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
sync_state: MangaSyncState;
|
||||||
|
chapter_count: number;
|
||||||
|
latest_seen_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminMangasPage = {
|
||||||
|
items: AdminMangaRow[];
|
||||||
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAdminMangasOptions = {
|
||||||
|
search?: string;
|
||||||
|
syncState?: MangaSyncState;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAdminMangas(
|
||||||
|
opts: ListAdminMangasOptions = {}
|
||||||
|
): Promise<AdminMangasPage> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.search) params.set('search', opts.search);
|
||||||
|
if (opts.syncState) params.set('sync_state', opts.syncState);
|
||||||
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<AdminMangasPage>(`/v1/admin/mangas${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChapterSyncState =
|
||||||
|
| 'downloading'
|
||||||
|
| 'dropped'
|
||||||
|
| 'failed'
|
||||||
|
| 'not_downloaded'
|
||||||
|
| 'synced';
|
||||||
|
|
||||||
|
export type AdminChapterRow = {
|
||||||
|
id: string;
|
||||||
|
manga_id: string;
|
||||||
|
number: number;
|
||||||
|
title: string | null;
|
||||||
|
page_count: number;
|
||||||
|
created_at: string;
|
||||||
|
sync_state: ChapterSyncState;
|
||||||
|
latest_seen_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAdminChapters(mangaId: string): Promise<AdminChapterRow[]> {
|
||||||
|
return request<AdminChapterRow[]>(
|
||||||
|
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- system ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DiskStats = {
|
||||||
|
total_bytes: number;
|
||||||
|
used_bytes: number;
|
||||||
|
free_bytes: number;
|
||||||
|
percent_used: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryStats = {
|
||||||
|
total_bytes: number;
|
||||||
|
used_bytes: number;
|
||||||
|
percent_used: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CpuStats = {
|
||||||
|
percent_used: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Alert = {
|
||||||
|
level: 'warning';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemStats = {
|
||||||
|
disk: DiskStats | null;
|
||||||
|
memory: MemoryStats;
|
||||||
|
cpu: CpuStats;
|
||||||
|
alerts: Alert[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
|
return request<SystemStats>('/v1/admin/system');
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export type User = {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
is_admin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Credentials = {
|
export type Credentials = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||||
import LogOut from '@lucide/svelte/icons/log-out';
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
|
import Shield from '@lucide/svelte/icons/shield';
|
||||||
import '$lib/styles/tokens.css';
|
import '$lib/styles/tokens.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -86,6 +87,12 @@
|
|||||||
<FolderOpen size={18} aria-hidden="true" />
|
<FolderOpen size={18} aria-hidden="true" />
|
||||||
<span>Collections</span>
|
<span>Collections</span>
|
||||||
</a>
|
</a>
|
||||||
|
{#if session.user?.is_admin}
|
||||||
|
<a class="nav-link" href="/admin" data-testid="nav-admin">
|
||||||
|
<Shield size={18} aria-hidden="true" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="session" data-testid="session-area">
|
<div class="session" data-testid="session-area">
|
||||||
{#if !session.loaded}
|
{#if !session.loaded}
|
||||||
|
|||||||
75
frontend/src/routes/admin/+layout.svelte
Normal file
75
frontend/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: '/admin', label: 'Overview' },
|
||||||
|
{ href: '/admin/users', label: 'Users' },
|
||||||
|
{ href: '/admin/mangas', label: 'Mangas' },
|
||||||
|
{ href: '/admin/system', label: 'System' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-frame">
|
||||||
|
<aside aria-label="admin">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<nav>
|
||||||
|
{#each tabs as t (t.href)}
|
||||||
|
<a
|
||||||
|
href={t.href}
|
||||||
|
class:active={$page.url.pathname === t.href}
|
||||||
|
data-testid={`admin-nav-${t.label.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<section>
|
||||||
|
{@render children()}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.admin-frame {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aside {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--app-header-h) + var(--space-4));
|
||||||
|
}
|
||||||
|
aside h2 {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
nav a.active {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
frontend/src/routes/admin/+layout.ts
Normal file
31
frontend/src/routes/admin/+layout.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// /admin gate. The backend's RequireAdmin extractor is the actual
|
||||||
|
// security boundary — this load function just calls a tiny admin
|
||||||
|
// endpoint and translates the response into either a redirect (no
|
||||||
|
// session) or SvelteKit's framework error page (403 forbidden).
|
||||||
|
// The session.user?.is_admin check elsewhere is UX only.
|
||||||
|
//
|
||||||
|
// `ssr=false` because the session store is browser-only (see
|
||||||
|
// $lib/session.svelte.ts) — server-side load can't read the cookie
|
||||||
|
// anyway in this app's deployment shape.
|
||||||
|
|
||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import { getSystemStats } from '$lib/api/admin';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async () => {
|
||||||
|
try {
|
||||||
|
const stats = await getSystemStats();
|
||||||
|
return { stats };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
throw redirect(302, '/login');
|
||||||
|
}
|
||||||
|
if (e instanceof ApiError && e.status === 403) {
|
||||||
|
throw error(403, 'admin access required');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
104
frontend/src/routes/admin/+page.svelte
Normal file
104
frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
let { data }: { data: LayoutData } = $props();
|
||||||
|
const stats = $derived(data.stats);
|
||||||
|
|
||||||
|
function fmtPercent(n: number): string {
|
||||||
|
return `${n.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = n;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Overview</h1>
|
||||||
|
|
||||||
|
{#if stats.alerts.length > 0}
|
||||||
|
<section class="alerts" data-testid="admin-alerts">
|
||||||
|
{#each stats.alerts as a (a.message)}
|
||||||
|
<div class="alert" data-level={a.level}>{a.message}</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="cards">
|
||||||
|
{#if stats.disk}
|
||||||
|
<article class="card">
|
||||||
|
<h3>Disk</h3>
|
||||||
|
<p class="metric">{fmtPercent(stats.disk.percent_used)}</p>
|
||||||
|
<p class="sub">{fmtBytes(stats.disk.used_bytes)} of {fmtBytes(stats.disk.total_bytes)} used</p>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<article class="card muted">
|
||||||
|
<h3>Disk</h3>
|
||||||
|
<p>n/a (non-local storage)</p>
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
<article class="card">
|
||||||
|
<h3>Memory</h3>
|
||||||
|
<p class="metric">{fmtPercent(stats.memory.percent_used)}</p>
|
||||||
|
<p class="sub">{fmtBytes(stats.memory.used_bytes)} of {fmtBytes(stats.memory.total_bytes)} used</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>CPU</h3>
|
||||||
|
<p class="metric">{fmtPercent(stats.cpu.percent_used)}</p>
|
||||||
|
<p class="sub">global load</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
}
|
||||||
|
.alerts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-left: 4px solid var(--warning, #f59e0b);
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.card.muted {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-xl, 1.5rem);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin: var(--space-1) 0 0 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
203
frontend/src/routes/admin/system/+page.svelte
Normal file
203
frontend/src/routes/admin/system/+page.svelte
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { getSystemStats, type SystemStats } from '$lib/api/admin';
|
||||||
|
|
||||||
|
let stats: SystemStats | null = $state(null);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
stats = await getSystemStats();
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'refresh failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refresh();
|
||||||
|
timer = setInterval(refresh, 5000);
|
||||||
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = n;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v.toFixed(2)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>System</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
{#if stats.alerts.length > 0}
|
||||||
|
<section class="alerts">
|
||||||
|
{#each stats.alerts as a (a.message)}
|
||||||
|
<div class="alert">{a.message}</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article>
|
||||||
|
<h2>Disk (storage_dir)</h2>
|
||||||
|
{#if stats.disk}
|
||||||
|
{@render Bar({ percent: stats.disk.percent_used })}
|
||||||
|
<dl>
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd>{fmtBytes(stats.disk.total_bytes)}</dd>
|
||||||
|
<dt>Used</dt>
|
||||||
|
<dd>{fmtBytes(stats.disk.used_bytes)}</dd>
|
||||||
|
<dt>Free</dt>
|
||||||
|
<dd>{fmtBytes(stats.disk.free_bytes)}</dd>
|
||||||
|
</dl>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">n/a — non-local storage backend</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h2>Memory</h2>
|
||||||
|
{@render Bar({ percent: stats.memory.percent_used })}
|
||||||
|
<dl>
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd>{fmtBytes(stats.memory.total_bytes)}</dd>
|
||||||
|
<dt>Used</dt>
|
||||||
|
<dd>{fmtBytes(stats.memory.used_bytes)}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h2>CPU</h2>
|
||||||
|
{@render Bar({ percent: stats.cpu.percent_used })}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="hint">refreshing every 5 s</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet Bar({ percent }: { percent: number })}
|
||||||
|
<div
|
||||||
|
class="bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={percent}
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-label="{percent.toFixed(1)}% used"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="fill"
|
||||||
|
class:high={percent >= 90}
|
||||||
|
class:mid={percent >= 70 && percent < 90}
|
||||||
|
style:width="{Math.min(100, Math.max(0, percent))}%"
|
||||||
|
></div>
|
||||||
|
<span class="label">{percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
article {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
article h2 {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
.fill.mid {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.fill.high {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--space-1) var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.alerts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.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>
|
||||||
184
frontend/src/routes/admin/users/+page.svelte
Normal file
184
frontend/src/routes/admin/users/+page.svelte
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
listAdminUsers,
|
||||||
|
deleteAdminUser,
|
||||||
|
setUserAdmin,
|
||||||
|
type AdminUsersPage
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
|
|
||||||
|
let page: AdminUsersPage | null = $state(null);
|
||||||
|
let search = $state('');
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let busyId: string | null = $state(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
page = await listAdminUsers({
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'load failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!confirm('Delete this user? This cannot be undone.')) return;
|
||||||
|
busyId = id;
|
||||||
|
try {
|
||||||
|
await deleteAdminUser(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'delete failed';
|
||||||
|
} finally {
|
||||||
|
busyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleAdmin(id: string, next: boolean) {
|
||||||
|
busyId = id;
|
||||||
|
try {
|
||||||
|
await setUserAdmin(id, next);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'update failed';
|
||||||
|
} finally {
|
||||||
|
busyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Users</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="search by username"
|
||||||
|
bind:value={search}
|
||||||
|
data-testid="admin-users-search"
|
||||||
|
/>
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if page}
|
||||||
|
<p class="total">{page.page.total ?? page.items.length} users</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each page.items as u (u.id)}
|
||||||
|
{@const isSelf = session.user?.id === u.id}
|
||||||
|
<tr>
|
||||||
|
<td>{u.username}{#if isSelf}<span class="self"> (you)</span>{/if}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={u.is_admin}
|
||||||
|
disabled={busyId === u.id || isSelf}
|
||||||
|
onchange={(e) => onToggleAdmin(u.id, e.currentTarget.checked)}
|
||||||
|
aria-label="admin"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
disabled={busyId === u.id || isSelf}
|
||||||
|
onclick={() => onDelete(u.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/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);
|
||||||
|
}
|
||||||
|
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.danger {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
border-color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.self {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.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