feat(analysis): admin coverage overview + per-page result inspector UI
Reworks /admin/analysis from a blind enqueue form into a coverage browser: - Loads a paginated overview of mangas with a CoverageBadge (Full/Partial/None, analyzed/total) per row; debounced search filters it. - Drill manga → chapters (coverage badge each) → page grid (chips colored done/queued/failed/none) with a legend. - Click a page chip → Modal showing the full result: status, model, time, NSFW + content-warning chips, scene description, tags, and OCR lines (labeled by kind). Unanalyzed/failed pages get a "Queue this page" action (force re-analyze). - Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed toggle; enqueue refreshes the open chapter's grid so queued pages show. API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages / PageDetail + types. New CoverageBadge component. Tests: vitest for the four clients; Playwright for overview+badges+queue-all, drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest. 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.75.0"
|
version = "0.76.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.75.0"
|
version = "0.76.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// E2E for the admin Analysis section: enqueue the whole library, or
|
// E2E for the admin Analysis section: coverage overview + badges, drill
|
||||||
// search a manga and enqueue it / one of its chapters, with the
|
// manga → chapter → page, the page-detail modal, and the enqueue actions.
|
||||||
// include-already-analyzed toggle. Fully mocked.
|
// Fully mocked.
|
||||||
|
|
||||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||||
|
|
||||||
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
||||||
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
||||||
|
const pageDone = 'p1111111-1111-1111-1111-111111111111';
|
||||||
|
const pageNone = 'p2222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
const adminUser = {
|
const adminUser = {
|
||||||
id: 'u11111111-1111-1111-1111-111111111111',
|
id: 'u11111111-1111-1111-1111-111111111111',
|
||||||
@@ -23,149 +25,220 @@ const systemStats = {
|
|||||||
alerts: []
|
alerts: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const mangaRow = {
|
type Captured = { reenqueue: Record<string, unknown> | null; analyzeCalls: number };
|
||||||
id: mangaId,
|
|
||||||
title: 'Berserk',
|
|
||||||
status: 'ongoing',
|
|
||||||
cover_image_path: null,
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
updated_at: '2026-01-01T00:00:00Z',
|
|
||||||
sync_state: 'synced',
|
|
||||||
chapter_count: 1,
|
|
||||||
latest_seen_at: null
|
|
||||||
};
|
|
||||||
|
|
||||||
const chapterRow = {
|
async function mockAdmin(page: Page, cap: Captured) {
|
||||||
id: chapterId,
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
manga_id: mangaId,
|
r.fulfill({
|
||||||
number: 1,
|
|
||||||
title: 'The Brand',
|
|
||||||
page_count: 20,
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
sync_state: 'synced',
|
|
||||||
latest_seen_at: null
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/auth/me', (route) =>
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
route.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ user: adminUser })
|
body: JSON.stringify({ user: adminUser })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
route.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
route.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/admin/system', (route) =>
|
await page.route('**/api/v1/admin/system', (r) =>
|
||||||
route.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify(systemStats)
|
body: JSON.stringify(systemStats)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Manga search. Registered before the chapters route so the more
|
|
||||||
// specific chapters glob (added later) wins for chapter URLs.
|
// Coverage overview. Registered before the more specific routes below
|
||||||
await page.route('**/api/v1/admin/mangas**', (route) =>
|
// so the later-registered (more specific) globs win for their URLs.
|
||||||
route.fulfill({
|
await page.route('**/api/v1/admin/analysis/mangas**', (r) =>
|
||||||
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items: [mangaRow],
|
items: [
|
||||||
page: { limit: 20, offset: 0, total: 1 }
|
{
|
||||||
|
manga_id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
total_pages: 2,
|
||||||
|
analyzed_pages: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 25, offset: 0, total: 1 }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/admin/mangas/*/chapters**', (route) =>
|
await page.route('**/api/v1/admin/analysis/mangas/*/chapters', (r) =>
|
||||||
route.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items: [chapterRow],
|
items: [
|
||||||
page: { limit: 500, offset: 0, total: 1 }
|
{
|
||||||
|
chapter_id: chapterId,
|
||||||
|
number: 1,
|
||||||
|
title: 'The Brand',
|
||||||
|
total_pages: 2,
|
||||||
|
analyzed_pages: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
|
await page.route('**/api/v1/admin/analysis/chapters/*/pages', (r) =>
|
||||||
captured.body = JSON.parse(route.request().postData() ?? '{}');
|
r.fulfill({
|
||||||
return route.fulfill({
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{ page_id: pageDone, page_number: 1, status: 'done' },
|
||||||
|
{ page_id: pageNone, page_number: 2, status: 'none' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/admin/analysis/pages/${pageDone}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_id: pageDone,
|
||||||
|
page_number: 1,
|
||||||
|
chapter_id: chapterId,
|
||||||
|
manga_id: mangaId,
|
||||||
|
status: 'done',
|
||||||
|
is_nsfw: true,
|
||||||
|
scene_description: 'A rainy street at night.',
|
||||||
|
model: 'test-model',
|
||||||
|
error: null,
|
||||||
|
analyzed_at: '2026-06-13T12:00:00Z',
|
||||||
|
ocr: [{ kind: 'speech', text: 'Hello there' }],
|
||||||
|
tags: ['action', 'city'],
|
||||||
|
content_warnings: ['gore']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/admin/analysis/pages/${pageNone}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_id: pageNone,
|
||||||
|
page_number: 2,
|
||||||
|
chapter_id: chapterId,
|
||||||
|
manga_id: mangaId,
|
||||||
|
status: 'none',
|
||||||
|
is_nsfw: false,
|
||||||
|
scene_description: null,
|
||||||
|
model: null,
|
||||||
|
error: null,
|
||||||
|
analyzed_at: null,
|
||||||
|
ocr: [],
|
||||||
|
tags: [],
|
||||||
|
content_warnings: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
|
||||||
|
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
|
||||||
|
return r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ enqueued: 12 })
|
body: JSON.stringify({ enqueued: 12 })
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(`**/api/v1/admin/pages/${pageNone}/analyze`, (r) => {
|
||||||
|
cap.analyzeCalls += 1;
|
||||||
|
return r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ enqueued: true })
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('/admin/analysis', () => {
|
test.describe('/admin/analysis', () => {
|
||||||
test('queue the whole library posts only_unanalyzed=true', async ({ page }) => {
|
test('overview shows coverage badges and queues the whole library', async ({
|
||||||
const captured: Captured = { body: null };
|
page
|
||||||
await mockAdmin(page, captured);
|
}) => {
|
||||||
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||||
|
await mockAdmin(page, cap);
|
||||||
await page.setViewportSize(DESKTOP);
|
await page.setViewportSize(DESKTOP);
|
||||||
await page.goto('/admin/analysis');
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
await page.getByTestId('admin-analysis-enqueue-library').click();
|
const badge = page.getByTestId(`admin-analysis-coverage-manga-${mangaId}`);
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toContainText('Partial 1/2');
|
||||||
|
|
||||||
|
await page.getByTestId('admin-analysis-enqueue-library').click();
|
||||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||||
'Enqueued 12 pages'
|
'Enqueued 12 pages'
|
||||||
);
|
);
|
||||||
expect(captured.body).toEqual({ only_unanalyzed: true });
|
expect(cap.reenqueue).toEqual({ only_unanalyzed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search a manga and queue it', async ({ page }) => {
|
test('drill manga → chapter → page and inspect the result', async ({ page }) => {
|
||||||
const captured: Captured = { body: null };
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||||
await mockAdmin(page, captured);
|
await mockAdmin(page, cap);
|
||||||
await page.setViewportSize(DESKTOP);
|
await page.setViewportSize(DESKTOP);
|
||||||
await page.goto('/admin/analysis');
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
await page.getByTestId('admin-analysis-search').fill('ber');
|
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`admin-analysis-manga-${mangaId}`)
|
page.getByTestId(`admin-analysis-coverage-chapter-${chapterId}`)
|
||||||
).toBeVisible();
|
).toContainText('Partial 1/2');
|
||||||
|
|
||||||
await page.getByTestId(`admin-analysis-enqueue-manga-${mangaId}`).click();
|
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
||||||
|
const doneChip = page.getByTestId(`admin-analysis-page-${pageDone}`);
|
||||||
|
await expect(doneChip).toHaveAttribute('data-status', 'done');
|
||||||
|
|
||||||
|
await doneChip.click();
|
||||||
|
const modal = page.getByTestId('admin-analysis-detail');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('admin-analysis-detail-status')
|
||||||
|
).toContainText('Analyzed');
|
||||||
|
await expect(modal).toContainText('Hello there');
|
||||||
|
await expect(modal).toContainText('action');
|
||||||
|
await expect(page.getByTestId('admin-analysis-detail-warnings')).toContainText(
|
||||||
|
'gore'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
|
||||||
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||||
|
await mockAdmin(page, cap);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
|
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
||||||
|
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
||||||
|
await page.getByTestId(`admin-analysis-page-${pageNone}`).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
|
||||||
|
'Not analyzed'
|
||||||
|
);
|
||||||
|
await page.getByTestId('admin-analysis-detail-queue').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||||
'Berserk'
|
'Queued page 2'
|
||||||
);
|
);
|
||||||
expect(captured.body).toEqual({ only_unanalyzed: true, manga_id: mangaId });
|
expect(cap.analyzeCalls).toBe(1);
|
||||||
});
|
|
||||||
|
|
||||||
test('expand chapters and queue one with include-analyzed on', 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-include-analyzed').check();
|
|
||||||
await page.getByTestId('admin-analysis-search').fill('ber');
|
|
||||||
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
|
||||||
|
|
||||||
await page.getByTestId(`admin-analysis-enqueue-chapter-${chapterId}`).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
|
|
||||||
expect(captured.body).toEqual({
|
|
||||||
only_unanalyzed: false,
|
|
||||||
chapter_id: chapterId
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.75.0",
|
"version": "0.76.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import {
|
|||||||
listActiveJobs,
|
listActiveJobs,
|
||||||
listMissingCovers,
|
listMissingCovers,
|
||||||
reenqueueAnalysis,
|
reenqueueAnalysis,
|
||||||
analyzePage
|
analyzePage,
|
||||||
|
getAnalysisMangaCoverage,
|
||||||
|
getAnalysisChapterCoverage,
|
||||||
|
getAnalysisChapterPages,
|
||||||
|
getAnalysisPageDetail
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -534,4 +538,46 @@ describe('admin crawler api client', () => {
|
|||||||
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
|
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
|
||||||
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getAnalysisMangaCoverage passes search + pagination', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
|
||||||
|
);
|
||||||
|
await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 });
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('/v1/admin/analysis/mangas?');
|
||||||
|
expect(url).toContain('search=ber');
|
||||||
|
expect(url).toContain('limit=25');
|
||||||
|
expect(url).toContain('offset=50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAnalysisChapterCoverage unwraps items', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
|
||||||
|
);
|
||||||
|
const items = await getAnalysisChapterCoverage('m1');
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAnalysisChapterPages unwraps items', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] })
|
||||||
|
);
|
||||||
|
const items = await getAnalysisChapterPages('c1');
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/);
|
||||||
|
expect(items[0].status).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAnalysisPageDetail hits the page endpoint', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] })
|
||||||
|
);
|
||||||
|
const d = await getAnalysisPageDetail('p1');
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
|
||||||
|
expect(d.status).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { request, apiUrl, type Page } from './client';
|
|||||||
import type { User } from './auth';
|
import type { User } from './auth';
|
||||||
import type { MangaDetail } from './mangas';
|
import type { MangaDetail } from './mangas';
|
||||||
import type { Chapter } from './chapters';
|
import type { Chapter } from './chapters';
|
||||||
|
import type { ContentWarning } from './page_tags';
|
||||||
|
|
||||||
// ---- users -----------------------------------------------------------------
|
// ---- users -----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -441,3 +442,93 @@ export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }
|
|||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- AI content analysis: coverage & inspection ----------------------------
|
||||||
|
|
||||||
|
export type MangaCoverage = {
|
||||||
|
manga_id: string;
|
||||||
|
title: string;
|
||||||
|
total_pages: number;
|
||||||
|
analyzed_pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MangaCoveragePage = {
|
||||||
|
items: MangaCoverage[];
|
||||||
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChapterCoverage = {
|
||||||
|
chapter_id: string;
|
||||||
|
number: number;
|
||||||
|
title: string | null;
|
||||||
|
total_pages: number;
|
||||||
|
analyzed_pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-page status in a chapter grid. */
|
||||||
|
export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none';
|
||||||
|
|
||||||
|
export type PageStatusItem = {
|
||||||
|
page_id: string;
|
||||||
|
page_number: number;
|
||||||
|
status: PageAnalysisStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OcrLine = { kind: string; text: string };
|
||||||
|
|
||||||
|
export type PageAnalysisDetail = {
|
||||||
|
page_id: string;
|
||||||
|
page_number: number;
|
||||||
|
chapter_id: string;
|
||||||
|
manga_id: string;
|
||||||
|
/** `done` | `failed` | `none` (no analysis row yet). */
|
||||||
|
status: 'done' | 'failed' | 'none';
|
||||||
|
is_nsfw: boolean;
|
||||||
|
scene_description: string | null;
|
||||||
|
model: string | null;
|
||||||
|
error: string | null;
|
||||||
|
analyzed_at: string | null;
|
||||||
|
ocr: OcrLine[];
|
||||||
|
tags: string[];
|
||||||
|
content_warnings: ContentWarning[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Paginated per-manga analysis coverage (admin overview). */
|
||||||
|
export async function getAnalysisMangaCoverage(
|
||||||
|
opts: { search?: string; limit?: number; offset?: number } = {}
|
||||||
|
): Promise<MangaCoveragePage> {
|
||||||
|
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<MangaCoveragePage>(
|
||||||
|
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalysisChapterCoverage(
|
||||||
|
mangaId: string
|
||||||
|
): Promise<ChapterCoverage[]> {
|
||||||
|
const r = await request<{ items: ChapterCoverage[] }>(
|
||||||
|
`/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
|
||||||
|
);
|
||||||
|
return r.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalysisChapterPages(
|
||||||
|
chapterId: string
|
||||||
|
): Promise<PageStatusItem[]> {
|
||||||
|
const r = await request<{ items: PageStatusItem[] }>(
|
||||||
|
`/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
|
||||||
|
);
|
||||||
|
return r.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalysisPageDetail(
|
||||||
|
pageId: string
|
||||||
|
): Promise<PageAnalysisDetail> {
|
||||||
|
return request<PageAnalysisDetail>(
|
||||||
|
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
56
frontend/src/lib/components/CoverageBadge.svelte
Normal file
56
frontend/src/lib/components/CoverageBadge.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Analysis-coverage badge: `analyzed/total` colored by state —
|
||||||
|
* green Full, amber Partial, grey None. Reused for manga and chapter
|
||||||
|
* rows in the admin Analysis section.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
analyzed,
|
||||||
|
total,
|
||||||
|
testid
|
||||||
|
}: { analyzed: number; total: number; testid?: string } = $props();
|
||||||
|
|
||||||
|
const state = $derived(
|
||||||
|
total === 0 || analyzed === 0
|
||||||
|
? 'none'
|
||||||
|
: analyzed >= total
|
||||||
|
? 'full'
|
||||||
|
: 'partial'
|
||||||
|
);
|
||||||
|
const word = $derived(
|
||||||
|
state === 'full' ? 'Full' : state === 'partial' ? 'Partial' : 'None'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="badge {state}" data-testid={testid} data-state={state}>
|
||||||
|
{word} {analyzed}/{total}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.full {
|
||||||
|
background: color-mix(in srgb, #2e7d32 16%, transparent);
|
||||||
|
color: #2e7d32;
|
||||||
|
border-color: color-mix(in srgb, #2e7d32 40%, transparent);
|
||||||
|
}
|
||||||
|
.partial {
|
||||||
|
background: color-mix(in srgb, #d4762a 16%, transparent);
|
||||||
|
color: #b85e1a;
|
||||||
|
border-color: color-mix(in srgb, #d4762a 40%, transparent);
|
||||||
|
}
|
||||||
|
.none {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,78 +1,174 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import {
|
import {
|
||||||
reenqueueAnalysis,
|
reenqueueAnalysis,
|
||||||
listAdminMangas,
|
analyzePage,
|
||||||
listAdminChapters,
|
getAnalysisMangaCoverage,
|
||||||
type AdminMangaRow,
|
getAnalysisChapterCoverage,
|
||||||
type AdminChapterRow,
|
getAnalysisChapterPages,
|
||||||
|
getAnalysisPageDetail,
|
||||||
|
type MangaCoverage,
|
||||||
|
type ChapterCoverage,
|
||||||
|
type PageStatusItem,
|
||||||
|
type PageAnalysisDetail,
|
||||||
type ReenqueueAnalysisOptions
|
type ReenqueueAnalysisOptions
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
|
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
|
||||||
// One global toggle applied to whichever enqueue action runs.
|
const LIMIT = 25;
|
||||||
|
|
||||||
|
// Global enqueue toggle.
|
||||||
let includeAnalyzed = $state(false);
|
let includeAnalyzed = $state(false);
|
||||||
|
|
||||||
// Manga search.
|
// Coverage overview / search.
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let mangas = $state<AdminMangaRow[]>([]);
|
|
||||||
let searching = $state(false);
|
|
||||||
let searched = $state(false);
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let mangas = $state<MangaCoverage[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let offset = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
|
||||||
// Per-manga chapter expansion: id → loading | rows.
|
// Drill-down state.
|
||||||
let expandedId = $state<string | null>(null);
|
let expandedManga = $state<string | null>(null);
|
||||||
let chapters = $state<Record<string, 'loading' | AdminChapterRow[]>>({});
|
let chapters = $state<Record<string, 'loading' | ChapterCoverage[]>>({});
|
||||||
|
let expandedChapter = $state<string | null>(null);
|
||||||
|
let pages = $state<Record<string, 'loading' | PageStatusItem[]>>({});
|
||||||
|
|
||||||
// In-flight action key ('library' | `manga:<id>` | `chapter:<id>`).
|
// Page-detail modal.
|
||||||
|
let detailOpen = $state(false);
|
||||||
|
let detail = $state<PageAnalysisDetail | null>(null);
|
||||||
|
let detailLoading = $state(false);
|
||||||
|
let detailError = $state<string | null>(null);
|
||||||
|
let detailQueueBusy = $state(false);
|
||||||
|
|
||||||
|
// Enqueue feedback.
|
||||||
let busyKey = $state<string | null>(null);
|
let busyKey = $state<string | null>(null);
|
||||||
let notice = $state<string | null>(null);
|
let notice = $state<string | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadCoverage(true);
|
||||||
|
});
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(runSearch, 300);
|
searchTimer = setTimeout(() => loadCoverage(true), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSearch() {
|
async function loadCoverage(reset: boolean) {
|
||||||
const q = query.trim();
|
if (reset) {
|
||||||
if (!q) {
|
offset = 0;
|
||||||
mangas = [];
|
loading = true;
|
||||||
searched = false;
|
// Collapse open rows so stale chapter/page state doesn't linger.
|
||||||
return;
|
expandedManga = null;
|
||||||
|
expandedChapter = null;
|
||||||
|
} else {
|
||||||
|
loadingMore = true;
|
||||||
}
|
}
|
||||||
searching = true;
|
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const r = await listAdminMangas({ search: q, limit: 20 });
|
const r = await getAnalysisMangaCoverage({
|
||||||
mangas = r.items;
|
search: query.trim() || undefined,
|
||||||
searched = true;
|
limit: LIMIT,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
mangas = reset ? r.items : [...mangas, ...r.items];
|
||||||
|
total = r.page.total ?? mangas.length;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Search failed.';
|
error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
|
||||||
} finally {
|
} finally {
|
||||||
searching = false;
|
loading = false;
|
||||||
|
loadingMore = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleChapters(mangaId: string) {
|
async function loadMore() {
|
||||||
if (expandedId === mangaId) {
|
offset += LIMIT;
|
||||||
expandedId = null;
|
await loadCoverage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleManga(id: string) {
|
||||||
|
if (expandedManga === id) {
|
||||||
|
expandedManga = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expandedId = mangaId;
|
expandedManga = id;
|
||||||
if (!chapters[mangaId]) {
|
expandedChapter = null;
|
||||||
chapters[mangaId] = 'loading';
|
if (!chapters[id]) {
|
||||||
|
chapters[id] = 'loading';
|
||||||
try {
|
try {
|
||||||
const r = await listAdminChapters(mangaId, { limit: 500 });
|
chapters[id] = await getAnalysisChapterCoverage(id);
|
||||||
chapters[mangaId] = r.items;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
delete chapters[mangaId];
|
delete chapters[id];
|
||||||
expandedId = null;
|
expandedManga = null;
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
|
error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleChapter(id: string) {
|
||||||
|
if (expandedChapter === id) {
|
||||||
|
expandedChapter = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandedChapter = id;
|
||||||
|
await loadPages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPages(id: string) {
|
||||||
|
pages[id] = 'loading';
|
||||||
|
try {
|
||||||
|
pages[id] = await getAnalysisChapterPages(id);
|
||||||
|
} catch (e) {
|
||||||
|
delete pages[id];
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load pages.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(pageId: string) {
|
||||||
|
detailOpen = true;
|
||||||
|
detailLoading = true;
|
||||||
|
detailError = null;
|
||||||
|
detail = null;
|
||||||
|
try {
|
||||||
|
detail = await getAnalysisPageDetail(pageId);
|
||||||
|
} catch (e) {
|
||||||
|
detailError = e instanceof ApiError ? e.message : 'Failed to load page.';
|
||||||
|
} finally {
|
||||||
|
detailLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
detailOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueFromDetail() {
|
||||||
|
if (!detail || detailQueueBusy) return;
|
||||||
|
detailQueueBusy = true;
|
||||||
|
detailError = null;
|
||||||
|
try {
|
||||||
|
await analyzePage(detail.page_id);
|
||||||
|
// Reflect the new queued state in the grid + modal.
|
||||||
|
if (expandedChapter) await loadPages(expandedChapter);
|
||||||
|
notice = `Queued page ${detail.page_number} for analysis.`;
|
||||||
|
closeDetail();
|
||||||
|
} catch (e) {
|
||||||
|
detailError =
|
||||||
|
e instanceof ApiError && e.status === 503
|
||||||
|
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
|
||||||
|
: e instanceof ApiError
|
||||||
|
? e.message
|
||||||
|
: 'Failed to queue page.';
|
||||||
|
} finally {
|
||||||
|
detailQueueBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function enqueue(
|
async function enqueue(
|
||||||
key: string,
|
key: string,
|
||||||
scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
|
scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
|
||||||
@@ -88,6 +184,8 @@
|
|||||||
onlyUnanalyzed: !includeAnalyzed
|
onlyUnanalyzed: !includeAnalyzed
|
||||||
});
|
});
|
||||||
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis (${label}).`;
|
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis (${label}).`;
|
||||||
|
// Refresh the open chapter's page grid so queued pages show.
|
||||||
|
if (expandedChapter) await loadPages(expandedChapter);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
error =
|
error =
|
||||||
@@ -103,14 +201,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
|
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
|
||||||
const enqueueManga = (m: AdminMangaRow) =>
|
const enqueueManga = (m: MangaCoverage) =>
|
||||||
enqueue(`manga:${m.id}`, { mangaId: m.id }, m.title);
|
enqueue(`manga:${m.manga_id}`, { mangaId: m.manga_id }, m.title);
|
||||||
const enqueueChapter = (m: AdminMangaRow, c: AdminChapterRow) =>
|
const enqueueChapter = (m: MangaCoverage, c: ChapterCoverage) =>
|
||||||
enqueue(
|
enqueue(
|
||||||
`chapter:${c.id}`,
|
`chapter:${c.chapter_id}`,
|
||||||
{ chapterId: c.id },
|
{ chapterId: c.chapter_id },
|
||||||
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
|
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -119,11 +222,12 @@
|
|||||||
|
|
||||||
<h1 class="heading">Content analysis</h1>
|
<h1 class="heading">Content analysis</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Queue page images for the AI analysis worker (OCR, auto-tags, scene
|
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
||||||
description, NSFW moderation).
|
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
||||||
|
page's result.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="checkbox" data-testid="admin-analysis-include-analyzed-wrap">
|
<label class="checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={includeAnalyzed}
|
bind:checked={includeAnalyzed}
|
||||||
@@ -133,127 +237,173 @@
|
|||||||
Include already-analyzed pages
|
Include already-analyzed pages
|
||||||
<span class="muted">
|
<span class="muted">
|
||||||
— re-runs analysis on pages that already have results (default
|
— re-runs analysis on pages that already have results (default
|
||||||
off: only un-analyzed pages are queued). Applies to every action
|
off). Applies to every "Queue" action.
|
||||||
below.
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<section class="panel" aria-label="Whole library">
|
<div class="row library-row">
|
||||||
<div class="row">
|
<span class="row-title">Whole library</span>
|
||||||
<div class="row-main">
|
<button
|
||||||
<span class="row-title">Whole library</span>
|
type="button"
|
||||||
<span class="row-sub">Every page of every chapter of every manga.</span>
|
disabled={busyKey !== null}
|
||||||
</div>
|
onclick={enqueueLibrary}
|
||||||
<button
|
data-testid="admin-analysis-enqueue-library"
|
||||||
type="button"
|
>
|
||||||
disabled={busyKey !== null}
|
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
|
||||||
onclick={enqueueLibrary}
|
</button>
|
||||||
data-testid="admin-analysis-enqueue-library"
|
</div>
|
||||||
>
|
|
||||||
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel" aria-label="Search a manga">
|
<label class="field">
|
||||||
<label class="field">
|
<span>Search</span>
|
||||||
<span>Search a manga to queue it or one of its chapters</span>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
bind:value={query}
|
||||||
bind:value={query}
|
oninput={onSearchInput}
|
||||||
oninput={onSearchInput}
|
placeholder="Filter mangas by title…"
|
||||||
placeholder="Title or author…"
|
data-testid="admin-analysis-search"
|
||||||
data-testid="admin-analysis-search"
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if searching}
|
{#if loading}
|
||||||
<p class="muted" data-testid="admin-analysis-searching">Searching…</p>
|
<p class="muted" data-testid="admin-analysis-loading">Loading coverage…</p>
|
||||||
{:else if searched && mangas.length === 0}
|
{:else if mangas.length === 0}
|
||||||
<p class="muted" data-testid="admin-analysis-no-results">
|
<p class="muted" data-testid="admin-analysis-empty">
|
||||||
No mangas match "{query.trim()}".
|
No mangas with pages{query.trim() ? ` match "${query.trim()}"` : ''}.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{:else}
|
||||||
|
<ul class="results" data-testid="admin-analysis-results">
|
||||||
|
{#each mangas as m (m.manga_id)}
|
||||||
|
<li class="manga" data-testid={`admin-analysis-manga-${m.manga_id}`}>
|
||||||
|
<div class="row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="disclosure"
|
||||||
|
aria-expanded={expandedManga === m.manga_id}
|
||||||
|
onclick={() => toggleManga(m.manga_id)}
|
||||||
|
data-testid={`admin-analysis-expand-${m.manga_id}`}
|
||||||
|
>
|
||||||
|
<span class="caret" class:open={expandedManga === m.manga_id}>▸</span>
|
||||||
|
<span class="row-main">
|
||||||
|
<span class="row-title">{m.title}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<CoverageBadge
|
||||||
|
analyzed={m.analyzed_pages}
|
||||||
|
total={m.total_pages}
|
||||||
|
testid={`admin-analysis-coverage-manga-${m.manga_id}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyKey !== null}
|
||||||
|
onclick={() => enqueueManga(m)}
|
||||||
|
data-testid={`admin-analysis-enqueue-manga-${m.manga_id}`}
|
||||||
|
>
|
||||||
|
{busyKey === `manga:${m.manga_id}` ? 'Queueing…' : 'Queue manga'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if mangas.length > 0}
|
{#if expandedManga === m.manga_id}
|
||||||
<ul class="results" data-testid="admin-analysis-results">
|
{#if chapters[m.manga_id] === 'loading'}
|
||||||
{#each mangas as m (m.id)}
|
<p class="muted indent">Loading chapters…</p>
|
||||||
<li class="manga" data-testid={`admin-analysis-manga-${m.id}`}>
|
{:else if Array.isArray(chapters[m.manga_id])}
|
||||||
<div class="row">
|
{@const chs = chapters[m.manga_id] as ChapterCoverage[]}
|
||||||
<button
|
<ul class="chapters">
|
||||||
type="button"
|
{#each chs as c (c.chapter_id)}
|
||||||
class="disclosure"
|
<li
|
||||||
aria-expanded={expandedId === m.id}
|
class="chapter"
|
||||||
onclick={() => toggleChapters(m.id)}
|
data-testid={`admin-analysis-chapter-${c.chapter_id}`}
|
||||||
data-testid={`admin-analysis-expand-${m.id}`}
|
>
|
||||||
>
|
<div class="row">
|
||||||
<span class="caret" class:open={expandedId === m.id}>▸</span>
|
<button
|
||||||
<span class="row-main">
|
type="button"
|
||||||
<span class="row-title">{m.title}</span>
|
class="disclosure"
|
||||||
<span class="row-sub">
|
aria-expanded={expandedChapter === c.chapter_id}
|
||||||
{m.chapter_count} chapter{m.chapter_count === 1
|
onclick={() => toggleChapter(c.chapter_id)}
|
||||||
? ''
|
data-testid={`admin-analysis-expand-chapter-${c.chapter_id}`}
|
||||||
: 's'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busyKey !== null}
|
|
||||||
onclick={() => enqueueManga(m)}
|
|
||||||
data-testid={`admin-analysis-enqueue-manga-${m.id}`}
|
|
||||||
>
|
|
||||||
{busyKey === `manga:${m.id}` ? 'Queueing…' : 'Queue manga'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if expandedId === m.id}
|
|
||||||
{#if chapters[m.id] === 'loading'}
|
|
||||||
<p class="muted chapters-loading">Loading chapters…</p>
|
|
||||||
{:else if Array.isArray(chapters[m.id])}
|
|
||||||
{@const rows = chapters[m.id] as AdminChapterRow[]}
|
|
||||||
{#if rows.length === 0}
|
|
||||||
<p class="muted chapters-loading">No chapters.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="chapters">
|
|
||||||
{#each rows as c (c.id)}
|
|
||||||
<li
|
|
||||||
class="chapter"
|
|
||||||
data-testid={`admin-analysis-chapter-${c.id}`}
|
|
||||||
>
|
>
|
||||||
<span class="chapter-label">
|
<span
|
||||||
{chapterLabel({
|
class="caret"
|
||||||
number: c.number,
|
class:open={expandedChapter === c.chapter_id}
|
||||||
title: c.title
|
>▸</span
|
||||||
})}
|
>
|
||||||
<span class="row-sub">
|
<span class="row-main">
|
||||||
{c.page_count} page{c.page_count === 1
|
<span class="chapter-label">
|
||||||
? ''
|
{chapterLabel({
|
||||||
: 's'}
|
number: c.number,
|
||||||
|
title: c.title
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<CoverageBadge
|
||||||
disabled={busyKey !== null}
|
analyzed={c.analyzed_pages}
|
||||||
onclick={() => enqueueChapter(m, c)}
|
total={c.total_pages}
|
||||||
data-testid={`admin-analysis-enqueue-chapter-${c.id}`}
|
testid={`admin-analysis-coverage-chapter-${c.chapter_id}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyKey !== null}
|
||||||
|
onclick={() => enqueueChapter(m, c)}
|
||||||
|
data-testid={`admin-analysis-enqueue-chapter-${c.chapter_id}`}
|
||||||
|
>
|
||||||
|
{busyKey === `chapter:${c.chapter_id}`
|
||||||
|
? 'Queueing…'
|
||||||
|
: 'Queue chapter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if expandedChapter === c.chapter_id}
|
||||||
|
{#if pages[c.chapter_id] === 'loading'}
|
||||||
|
<p class="muted indent">Loading pages…</p>
|
||||||
|
{:else if Array.isArray(pages[c.chapter_id])}
|
||||||
|
{@const ps = pages[c.chapter_id] as PageStatusItem[]}
|
||||||
|
<div
|
||||||
|
class="page-grid"
|
||||||
|
data-testid={`admin-analysis-pages-${c.chapter_id}`}
|
||||||
>
|
>
|
||||||
{busyKey === `chapter:${c.id}`
|
{#each ps as p (p.page_id)}
|
||||||
? 'Queueing…'
|
<button
|
||||||
: 'Queue chapter'}
|
type="button"
|
||||||
</button>
|
class="page-chip {p.status}"
|
||||||
</li>
|
title={`Page ${p.page_number} — ${p.status}`}
|
||||||
{/each}
|
onclick={() => openDetail(p.page_id)}
|
||||||
</ul>
|
data-testid={`admin-analysis-page-${p.page_id}`}
|
||||||
{/if}
|
data-status={p.status}
|
||||||
{/if}
|
>
|
||||||
|
{p.page_number}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="legend">
|
||||||
|
<span class="dot done"></span> analyzed
|
||||||
|
<span class="dot queued"></span> queued
|
||||||
|
<span class="dot failed"></span> failed
|
||||||
|
<span class="dot none"></span> not analyzed
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
{/if}
|
||||||
{/each}
|
</li>
|
||||||
</ul>
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if mangas.length < total}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="load-more"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onclick={loadMore}
|
||||||
|
data-testid="admin-analysis-load-more"
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Loading…' : `Load more (${mangas.length}/${total})`}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
{/if}
|
||||||
|
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
|
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
|
||||||
@@ -262,6 +412,89 @@
|
|||||||
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
|
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={detailOpen}
|
||||||
|
title={detail ? `Page ${detail.page_number}` : 'Page analysis'}
|
||||||
|
onClose={closeDetail}
|
||||||
|
size="lg"
|
||||||
|
closeOnBackdrop
|
||||||
|
testid="admin-analysis-detail"
|
||||||
|
>
|
||||||
|
{#if detailLoading}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if detailError}
|
||||||
|
<p class="error" role="alert">{detailError}</p>
|
||||||
|
{:else if detail}
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span class="status-pill {detail.status}" data-testid="admin-analysis-detail-status">
|
||||||
|
{detail.status === 'done'
|
||||||
|
? 'Analyzed'
|
||||||
|
: detail.status === 'failed'
|
||||||
|
? 'Failed'
|
||||||
|
: 'Not analyzed'}
|
||||||
|
</span>
|
||||||
|
{#if detail.model}<span class="muted">model {detail.model}</span>{/if}
|
||||||
|
{#if detail.analyzed_at}<span class="muted">{fmtDate(detail.analyzed_at)}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if detail.status === 'none'}
|
||||||
|
<p class="muted">This page hasn't been analyzed yet.</p>
|
||||||
|
{:else if detail.status === 'failed'}
|
||||||
|
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.is_nsfw || detail.content_warnings.length > 0}
|
||||||
|
<div class="warnings" data-testid="admin-analysis-detail-warnings">
|
||||||
|
<span class="cw-label">⚠ NSFW</span>
|
||||||
|
{#each detail.content_warnings as w (w)}
|
||||||
|
<span class="cw-chip">{w}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.scene_description}
|
||||||
|
<section class="block">
|
||||||
|
<h3>Scene</h3>
|
||||||
|
<p>{detail.scene_description}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.tags.length > 0}
|
||||||
|
<section class="block">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tag-row">
|
||||||
|
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.ocr.length > 0}
|
||||||
|
<section class="block">
|
||||||
|
<h3>OCR text</h3>
|
||||||
|
<ul class="ocr">
|
||||||
|
{#each detail.ocr as line, i (i)}
|
||||||
|
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if detail && detail.status !== 'done'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={detailQueueBusy}
|
||||||
|
onclick={queueFromDetail}
|
||||||
|
data-testid="admin-analysis-detail-queue"
|
||||||
|
>
|
||||||
|
{detailQueueBusy ? 'Queueing…' : 'Queue this page'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={closeDetail}>Close</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.heading {
|
.heading {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
@@ -269,7 +502,7 @@
|
|||||||
.lede {
|
.lede {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
max-width: 42rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
.checkbox {
|
.checkbox {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -277,24 +510,28 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-3);
|
||||||
max-width: 42rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.panel {
|
.indent {
|
||||||
max-width: 46rem;
|
margin-left: var(--space-5);
|
||||||
padding: var(--space-3) var(--space-4);
|
}
|
||||||
|
.library-row {
|
||||||
|
max-width: 44rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg, 12px);
|
border-radius: var(--radius-md);
|
||||||
background: var(--surface);
|
margin-bottom: var(--space-3);
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
}
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
.field > span {
|
.field > span {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -303,7 +540,7 @@
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.row-main {
|
.row-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -312,22 +549,18 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.row-title {
|
.row-title,
|
||||||
|
.chapter-label {
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.row-sub {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
.results {
|
.results {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: var(--space-3) 0 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
max-width: 44rem;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
.manga {
|
.manga {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -360,26 +593,160 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
.chapter {
|
.page-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin: var(--space-2) 0 0 var(--space-5);
|
||||||
|
}
|
||||||
|
.page-chip {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.page-chip.done {
|
||||||
|
background: color-mix(in srgb, #2e7d32 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #2e7d32 45%, transparent);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.page-chip.queued {
|
||||||
|
background: color-mix(in srgb, #2563eb 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #2563eb 45%, transparent);
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.page-chip.failed {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-2) 0 0 var(--space-5);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.chapter-label {
|
.dot {
|
||||||
flex: 1;
|
display: inline-block;
|
||||||
min-width: 0;
|
width: 10px;
|
||||||
display: flex;
|
height: 10px;
|
||||||
flex-direction: column;
|
border-radius: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.chapters-loading {
|
.dot.done {
|
||||||
margin: var(--space-2) 0 0 var(--space-4);
|
background: #2e7d32;
|
||||||
|
}
|
||||||
|
.dot.queued {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.dot.failed {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
.dot.none {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.load-more {
|
||||||
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
.notice {
|
.notice {
|
||||||
color: var(--success, #2e7d32);
|
color: var(--success, #2e7d32);
|
||||||
max-width: 46rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
max-width: 46rem;
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail modal */
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.status-pill {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.status-pill.done {
|
||||||
|
background: color-mix(in srgb, #2e7d32 16%, transparent);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.status-pill.failed {
|
||||||
|
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.warnings {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, #d4762a 12%, transparent);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.cw-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b85e1a;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.cw-chip {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, #d4762a 22%, transparent);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.block h3 {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
}
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.ocr {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.ocr-kind {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user