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

@@ -73,7 +73,12 @@ const collectionFixture = {
*/
async function mockReader(
page: Page,
state: { collectionsContainingPage: string[]; tagsOnPage: string[] }
state: {
collectionsContainingPage: string[];
tagsOnPage: string[];
admin?: boolean;
analyzeCalls?: number;
}
) {
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
@@ -86,9 +91,20 @@ async function mockReader(
route.fulfill({
status: 200,
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) =>
route.fulfill({
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 ({
page
}) => {