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:
@@ -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>
|
||||
|
||||
246
frontend/src/routes/admin/analysis/+page.svelte
Normal file
246
frontend/src/routes/admin/analysis/+page.svelte
Normal 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 manga’s 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>
|
||||
Reference in New Issue
Block a user