feat(analysis): admin UI — reader context-menu action + dashboard section

Surfaces the scoped re-enqueue and per-page force-analyze in the UI:

- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
  analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
  (canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
  endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
  (whole library / one manga / one chapter), id input, and an
  "include already-analyzed" toggle (default off), with busy/notice/error.

Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 19:57:03 +02:00
parent 9607488278
commit 8b7ea2e1b2
11 changed files with 551 additions and 8 deletions

View File

@@ -7,6 +7,7 @@
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/mangas', label: 'Mangas' },
{ href: '/admin/crawler', label: 'Crawler' },
{ href: '/admin/analysis', label: 'Analysis' },
{ href: '/admin/system', label: 'System' }
];
</script>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { ApiError } from '$lib/api/client';
import { reenqueueAnalysis } from '$lib/api/admin';
type Scope = 'all' | 'manga' | 'chapter';
let scope = $state<Scope>('all');
let mangaId = $state('');
let chapterId = $state('');
// Default: skip pages that already have a completed analysis.
let includeAnalyzed = $state(false);
let busy = $state(false);
let error = $state<string | null>(null);
let notice = $state<string | null>(null);
const scopeOptions: { value: Scope; label: string; hint: string }[] = [
{
value: 'all',
label: 'Whole library',
hint: 'Every page of every chapter of every manga.'
},
{
value: 'manga',
label: 'One manga',
hint: 'All pages across the chosen mangas chapters.'
},
{ value: 'chapter', label: 'One chapter', hint: 'All pages of the chosen chapter.' }
];
async function submit(e: SubmitEvent) {
e.preventDefault();
if (busy) return;
error = null;
notice = null;
const opts: {
onlyUnanalyzed: boolean;
mangaId?: string;
chapterId?: string;
} = { onlyUnanalyzed: !includeAnalyzed };
if (scope === 'manga') {
if (!mangaId.trim()) {
error = 'Enter a manga id.';
return;
}
opts.mangaId = mangaId.trim();
} else if (scope === 'chapter') {
if (!chapterId.trim()) {
error = 'Enter a chapter id.';
return;
}
opts.chapterId = chapterId.trim();
}
busy = true;
try {
const r = await reenqueueAnalysis(opts);
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis.`;
} catch (err) {
if (err instanceof ApiError) {
error =
err.status === 503
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
: err.message;
} else {
error = 'Failed to enqueue analysis.';
}
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>Mangalord | Admin — Analysis</title>
</svelte:head>
<h1 class="heading">Content analysis</h1>
<p class="lede">
Queue page images for the AI analysis worker (OCR, auto-tags, scene
description, NSFW moderation). Pick a scope and run it.
</p>
<form class="panel" onsubmit={submit} data-testid="admin-analysis-form">
<fieldset class="scopes">
<legend>Scope</legend>
{#each scopeOptions as o (o.value)}
<label class="scope-opt" class:active={scope === o.value}>
<input
type="radio"
name="scope"
value={o.value}
checked={scope === o.value}
onchange={() => (scope = o.value)}
data-testid={`admin-analysis-scope-${o.value}`}
/>
<span class="scope-label">{o.label}</span>
<span class="scope-hint">{o.hint}</span>
</label>
{/each}
</fieldset>
{#if scope === 'manga'}
<label class="field">
<span>Manga id</span>
<input
type="text"
bind:value={mangaId}
placeholder="uuid"
data-testid="admin-analysis-manga-id"
/>
</label>
{:else if scope === 'chapter'}
<label class="field">
<span>Chapter id</span>
<input
type="text"
bind:value={chapterId}
placeholder="uuid"
data-testid="admin-analysis-chapter-id"
/>
</label>
{/if}
<label class="checkbox">
<input
type="checkbox"
bind:checked={includeAnalyzed}
data-testid="admin-analysis-include-analyzed"
/>
<span>
Include already-analyzed pages
<span class="muted">
— re-runs analysis on pages that already have results
(default off: only un-analyzed pages are queued).
</span>
</span>
</label>
<div class="actions">
<button type="submit" disabled={busy} data-testid="admin-analysis-submit">
{busy ? 'Queueing…' : 'Queue for analysis'}
</button>
</div>
{#if notice}
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
{/if}
{#if error}
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
</form>
<style>
.heading {
margin-bottom: var(--space-2);
}
.lede {
color: var(--text-muted);
margin-bottom: var(--space-4);
max-width: 42rem;
}
.panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 42rem;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg, 12px);
background: var(--surface);
}
fieldset.scopes {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
legend {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-1);
}
.scope-opt {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--space-2);
align-items: baseline;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
}
.scope-opt.active {
border-color: var(--primary);
background: var(--primary-soft-bg);
}
.scope-opt input {
grid-row: 1 / span 2;
align-self: center;
}
.scope-label {
font-weight: var(--weight-semibold);
}
.scope-hint {
grid-column: 2;
color: var(--text-muted);
font-size: var(--font-sm);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field span {
color: var(--text-muted);
font-size: var(--font-sm);
}
.field input {
max-width: 26rem;
}
.checkbox {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2);
align-items: start;
font-size: var(--font-sm);
}
.checkbox .muted {
color: var(--text-muted);
}
.actions {
display: flex;
gap: var(--space-2);
}
.notice {
color: var(--success, #2e7d32);
}
.error {
color: var(--danger);
}
</style>