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

@@ -4,6 +4,7 @@
import Tag from '@lucide/svelte/icons/tag';
import Download from '@lucide/svelte/icons/download';
import Link2 from '@lucide/svelte/icons/link-2';
import Sparkles from '@lucide/svelte/icons/sparkles';
/**
* Floating context menu anchored at `(anchor.x, anchor.y)` in
@@ -25,7 +26,10 @@
onSaveImage,
onCopyLink,
collectionsCount,
tags
tags,
canAnalyze = false,
onAnalyzePage,
analyzeState = 'idle'
}: {
open: boolean;
anchor: { x: number; y: number };
@@ -41,6 +45,12 @@
collectionsCount: number | null;
/** May be empty. */
tags: string[];
/** Admin-only: show the "Queue for analysis" action. */
canAnalyze?: boolean;
/** Force-enqueue the current page for AI analysis. */
onAnalyzePage?: () => void;
/** Transient state for the analyze action's label. */
analyzeState?: 'idle' | 'busy' | 'done' | 'error';
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
@@ -170,6 +180,24 @@
<Link2 size={14} aria-hidden="true" />
<span>Copy page link</span>
</button>
{#if canAnalyze}
<div class="divider" aria-hidden="true"></div>
<button
type="button"
class="item"
onclick={onAnalyzePage}
disabled={analyzeState === 'busy'}
data-testid="page-context-analyze"
>
<Sparkles size={14} aria-hidden="true" />
<span>
{#if analyzeState === 'busy'}Queueing…
{:else if analyzeState === 'done'}Queued for analysis ✓
{:else if analyzeState === 'error'}Queue failed — retry
{:else}Queue for analysis{/if}
</span>
</button>
{/if}
<div class="divider" aria-hidden="true"></div>
<p class="hint" data-testid="page-context-collections-line">
{collectionsLine}