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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.72.0"
|
version = "0.73.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.72.0"
|
version = "0.73.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
124
frontend/e2e/admin-analysis.spec.ts
Normal file
124
frontend/e2e/admin-analysis.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// E2E for the admin Analysis section: scoped re-enqueue (all / manga /
|
||||||
|
// chapter) with the include-already-analyzed toggle. Fully mocked.
|
||||||
|
|
||||||
|
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 'u11111111-1111-1111-1111-111111111111',
|
||||||
|
username: 'admin',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
is_admin: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemStats = {
|
||||||
|
disk: null,
|
||||||
|
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
|
||||||
|
cpu: { percent_used: 0 },
|
||||||
|
alerts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
type Captured = { body: Record<string, unknown> | null };
|
||||||
|
|
||||||
|
async function mockAdmin(page: Page, captured: Captured) {
|
||||||
|
await page.route('**/api/v1/auth/config', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: adminUser })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/admin/system', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(systemStats)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
|
||||||
|
captured.body = JSON.parse(route.request().postData() ?? '{}');
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ enqueued: 12 })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('/admin/analysis', () => {
|
||||||
|
test('default whole-library re-enqueue posts only_unanalyzed=true', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const captured: Captured = { body: null };
|
||||||
|
await mockAdmin(page, captured);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('admin-analysis-form')).toBeVisible();
|
||||||
|
await page.getByTestId('admin-analysis-submit').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||||
|
'Enqueued 12 pages'
|
||||||
|
);
|
||||||
|
expect(captured.body).toEqual({ only_unanalyzed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const captured: Captured = { body: null };
|
||||||
|
await mockAdmin(page, captured);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
|
await page.getByTestId('admin-analysis-scope-manga').check();
|
||||||
|
await page
|
||||||
|
.getByTestId('admin-analysis-manga-id')
|
||||||
|
.fill('a9999999-9999-9999-9999-999999999999');
|
||||||
|
await page.getByTestId('admin-analysis-include-analyzed').check();
|
||||||
|
await page.getByTestId('admin-analysis-submit').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
|
||||||
|
expect(captured.body).toEqual({
|
||||||
|
only_unanalyzed: false,
|
||||||
|
manga_id: 'a9999999-9999-9999-9999-999999999999'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chapter scope requires an id before posting', async ({ page }) => {
|
||||||
|
const captured: Captured = { body: null };
|
||||||
|
await mockAdmin(page, captured);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
|
await page.getByTestId('admin-analysis-scope-chapter').check();
|
||||||
|
await page.getByTestId('admin-analysis-submit').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('admin-analysis-error')).toContainText(
|
||||||
|
'Enter a chapter id'
|
||||||
|
);
|
||||||
|
expect(captured.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,7 +73,12 @@ const collectionFixture = {
|
|||||||
*/
|
*/
|
||||||
async function mockReader(
|
async function mockReader(
|
||||||
page: Page,
|
page: Page,
|
||||||
state: { collectionsContainingPage: string[]; tagsOnPage: string[] }
|
state: {
|
||||||
|
collectionsContainingPage: string[];
|
||||||
|
tagsOnPage: string[];
|
||||||
|
admin?: boolean;
|
||||||
|
analyzeCalls?: number;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
await page.route('**/api/v1/auth/config', (route) =>
|
await page.route('**/api/v1/auth/config', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
@@ -86,9 +91,20 @@ async function mockReader(
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ user: userFixture })
|
body: JSON.stringify({
|
||||||
|
user: { ...userFixture, is_admin: state.admin ?? false }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
// Admin force-analyze endpoint — counts invocations for assertions.
|
||||||
|
await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => {
|
||||||
|
state.analyzeCalls = (state.analyzeCalls ?? 0) + 1;
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ enqueued: true })
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -266,6 +282,37 @@ test.describe('page context menu (desktop right-click)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('non-admin does not see the Queue for analysis action', async ({ page }) => {
|
||||||
|
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
|
await page.getByTestId('reader-page').click({ button: 'right' });
|
||||||
|
await expect(page.getByTestId('page-context-menu')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('page-context-analyze')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can queue the page for analysis from the menu', async ({ page }) => {
|
||||||
|
const state = {
|
||||||
|
collectionsContainingPage: [],
|
||||||
|
tagsOnPage: [],
|
||||||
|
admin: true,
|
||||||
|
analyzeCalls: 0
|
||||||
|
};
|
||||||
|
await mockReader(page, state);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
|
await page.getByTestId('reader-page').click({ button: 'right' });
|
||||||
|
const item = page.getByTestId('page-context-analyze');
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
await expect(item).toContainText('Queue for analysis');
|
||||||
|
|
||||||
|
await item.click();
|
||||||
|
await expect(item).toContainText('Queued for analysis');
|
||||||
|
expect(state.analyzeCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('Shift+right-click falls through to the browser native menu', async ({
|
test('Shift+right-click falls through to the browser native menu', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.72.0",
|
"version": "0.73.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
listDeadJobs,
|
listDeadJobs,
|
||||||
requeueDeadJobs,
|
requeueDeadJobs,
|
||||||
listActiveJobs,
|
listActiveJobs,
|
||||||
listMissingCovers
|
listMissingCovers,
|
||||||
|
reenqueueAnalysis,
|
||||||
|
analyzePage
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -494,4 +496,42 @@ describe('admin crawler api client', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
|
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
|
||||||
await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
|
await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 }));
|
||||||
|
const r = await reenqueueAnalysis();
|
||||||
|
expect(r.enqueued).toBe(42);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/);
|
||||||
|
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
|
||||||
|
only_unanalyzed: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 }));
|
||||||
|
await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false });
|
||||||
|
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
|
||||||
|
only_unanalyzed: false,
|
||||||
|
manga_id: 'm1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reenqueueAnalysis scopes to a chapter', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 }));
|
||||||
|
await reenqueueAnalysis({ chapterId: 'c1' });
|
||||||
|
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
|
||||||
|
only_unanalyzed: true,
|
||||||
|
chapter_id: 'c1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyzePage posts to the force-analyze endpoint', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ enqueued: true }));
|
||||||
|
const r = await analyzePage('p1');
|
||||||
|
expect(r.enqueued).toBe(true);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
|
||||||
|
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -403,3 +403,41 @@ export async function listMissingCovers(
|
|||||||
init
|
init
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- AI content analysis ---------------------------------------------------
|
||||||
|
|
||||||
|
/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId`
|
||||||
|
* are mutually exclusive; omit both to target the whole library. */
|
||||||
|
export type ReenqueueAnalysisOptions = {
|
||||||
|
/** Skip pages that already have a completed analysis (default true).
|
||||||
|
* When false, in-scope pages are force re-analyzed. */
|
||||||
|
onlyUnanalyzed?: boolean;
|
||||||
|
mangaId?: string;
|
||||||
|
chapterId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's
|
||||||
|
* pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED
|
||||||
|
* on the backend (503 otherwise). */
|
||||||
|
export async function reenqueueAnalysis(
|
||||||
|
opts: ReenqueueAnalysisOptions = {}
|
||||||
|
): Promise<{ enqueued: number }> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
only_unanalyzed: opts.onlyUnanalyzed ?? true
|
||||||
|
};
|
||||||
|
if (opts.mangaId) body.manga_id = opts.mangaId;
|
||||||
|
if (opts.chapterId) body.chapter_id = opts.chapterId;
|
||||||
|
return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force re-analysis of a single page (used by the reader context menu). */
|
||||||
|
export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> {
|
||||||
|
return request<{ enqueued: boolean }>(
|
||||||
|
`/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Tag from '@lucide/svelte/icons/tag';
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
import Download from '@lucide/svelte/icons/download';
|
import Download from '@lucide/svelte/icons/download';
|
||||||
import Link2 from '@lucide/svelte/icons/link-2';
|
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
|
* Floating context menu anchored at `(anchor.x, anchor.y)` in
|
||||||
@@ -25,7 +26,10 @@
|
|||||||
onSaveImage,
|
onSaveImage,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
collectionsCount,
|
collectionsCount,
|
||||||
tags
|
tags,
|
||||||
|
canAnalyze = false,
|
||||||
|
onAnalyzePage,
|
||||||
|
analyzeState = 'idle'
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
anchor: { x: number; y: number };
|
anchor: { x: number; y: number };
|
||||||
@@ -41,6 +45,12 @@
|
|||||||
collectionsCount: number | null;
|
collectionsCount: number | null;
|
||||||
/** May be empty. */
|
/** May be empty. */
|
||||||
tags: string[];
|
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();
|
} = $props();
|
||||||
|
|
||||||
let menuEl: HTMLDivElement | undefined = $state();
|
let menuEl: HTMLDivElement | undefined = $state();
|
||||||
@@ -170,6 +180,24 @@
|
|||||||
<Link2 size={14} aria-hidden="true" />
|
<Link2 size={14} aria-hidden="true" />
|
||||||
<span>Copy page link</span>
|
<span>Copy page link</span>
|
||||||
</button>
|
</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>
|
<div class="divider" aria-hidden="true"></div>
|
||||||
<p class="hint" data-testid="page-context-collections-line">
|
<p class="hint" data-testid="page-context-collections-line">
|
||||||
{collectionsLine}
|
{collectionsLine}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{ href: '/admin/users', label: 'Users' },
|
{ href: '/admin/users', label: 'Users' },
|
||||||
{ href: '/admin/mangas', label: 'Mangas' },
|
{ href: '/admin/mangas', label: 'Mangas' },
|
||||||
{ href: '/admin/crawler', label: 'Crawler' },
|
{ href: '/admin/crawler', label: 'Crawler' },
|
||||||
|
{ href: '/admin/analysis', label: 'Analysis' },
|
||||||
{ href: '/admin/system', label: 'System' }
|
{ href: '/admin/system', label: 'System' }
|
||||||
];
|
];
|
||||||
</script>
|
</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>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import { preferences } from '$lib/preferences.svelte';
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { updateReadProgress } from '$lib/api/read_progress';
|
import { updateReadProgress } from '$lib/api/read_progress';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { resyncChapter } from '$lib/api/admin';
|
import { resyncChapter, analyzePage } from '$lib/api/admin';
|
||||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Sheet from '$lib/components/Sheet.svelte';
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
@@ -118,6 +118,8 @@
|
|||||||
let actionSheetOpen = $state(false);
|
let actionSheetOpen = $state(false);
|
||||||
let collectionsModalOpen = $state(false);
|
let collectionsModalOpen = $state(false);
|
||||||
let tagsModalOpen = $state(false);
|
let tagsModalOpen = $state(false);
|
||||||
|
// Admin-only "Queue for analysis" action feedback, reset per page.
|
||||||
|
let analyzeState = $state<'idle' | 'busy' | 'done' | 'error'>('idle');
|
||||||
|
|
||||||
// Monotonically-increasing token so a slow loadPageSummary for
|
// Monotonically-increasing token so a slow loadPageSummary for
|
||||||
// page A can't clobber a fresh load for page B. The user right-
|
// page A can't clobber a fresh load for page B. The user right-
|
||||||
@@ -148,13 +150,27 @@
|
|||||||
activePageId = pageId;
|
activePageId = pageId;
|
||||||
contextMenuAnchor = anchor;
|
contextMenuAnchor = anchor;
|
||||||
contextMenuOpen = true;
|
contextMenuOpen = true;
|
||||||
|
analyzeState = 'idle';
|
||||||
void loadPageSummary(pageId);
|
void loadPageSummary(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAnalyzePage() {
|
||||||
|
const pageId = activePageId;
|
||||||
|
if (!pageId || !session.user?.is_admin || analyzeState === 'busy') return;
|
||||||
|
analyzeState = 'busy';
|
||||||
|
try {
|
||||||
|
await analyzePage(pageId);
|
||||||
|
analyzeState = 'done';
|
||||||
|
} catch {
|
||||||
|
analyzeState = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openActionSheet(pageId: string) {
|
function openActionSheet(pageId: string) {
|
||||||
if (!session.user) return;
|
if (!session.user) return;
|
||||||
activePageId = pageId;
|
activePageId = pageId;
|
||||||
actionSheetOpen = true;
|
actionSheetOpen = true;
|
||||||
|
analyzeState = 'idle';
|
||||||
void loadPageSummary(pageId);
|
void loadPageSummary(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,6 +1470,9 @@
|
|||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
collectionsCount={activePageCollectionCount}
|
collectionsCount={activePageCollectionCount}
|
||||||
tags={activePageTags}
|
tags={activePageTags}
|
||||||
|
canAnalyze={!!session.user?.is_admin}
|
||||||
|
onAnalyzePage={handleAnalyzePage}
|
||||||
|
{analyzeState}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
||||||
|
|||||||
Reference in New Issue
Block a user