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]]
|
||||
name = "mangalord"
|
||||
version = "0.72.0"
|
||||
version = "0.73.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.72.0"
|
||||
version = "0.73.0"
|
||||
edition = "2021"
|
||||
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(
|
||||
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
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.72.0",
|
||||
"version": "0.73.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
listDeadJobs,
|
||||
requeueDeadJobs,
|
||||
listActiveJobs,
|
||||
listMissingCovers
|
||||
listMissingCovers,
|
||||
reenqueueAnalysis,
|
||||
analyzePage
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -494,4 +496,42 @@ describe('admin crawler api client', () => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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 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}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/mangas', label: 'Mangas' },
|
||||
{ href: '/admin/crawler', label: 'Crawler' },
|
||||
{ href: '/admin/analysis', label: 'Analysis' },
|
||||
{ href: '/admin/system', label: 'System' }
|
||||
];
|
||||
</script>
|
||||
|
||||
246
frontend/src/routes/admin/analysis/+page.svelte
Normal file
246
frontend/src/routes/admin/analysis/+page.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { reenqueueAnalysis } from '$lib/api/admin';
|
||||
|
||||
type Scope = 'all' | 'manga' | 'chapter';
|
||||
|
||||
let scope = $state<Scope>('all');
|
||||
let mangaId = $state('');
|
||||
let chapterId = $state('');
|
||||
// Default: skip pages that already have a completed analysis.
|
||||
let includeAnalyzed = $state(false);
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
|
||||
const scopeOptions: { value: Scope; label: string; hint: string }[] = [
|
||||
{
|
||||
value: 'all',
|
||||
label: 'Whole library',
|
||||
hint: 'Every page of every chapter of every manga.'
|
||||
},
|
||||
{
|
||||
value: 'manga',
|
||||
label: 'One manga',
|
||||
hint: 'All pages across the chosen manga’s chapters.'
|
||||
},
|
||||
{ value: 'chapter', label: 'One chapter', hint: 'All pages of the chosen chapter.' }
|
||||
];
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
error = null;
|
||||
notice = null;
|
||||
|
||||
const opts: {
|
||||
onlyUnanalyzed: boolean;
|
||||
mangaId?: string;
|
||||
chapterId?: string;
|
||||
} = { onlyUnanalyzed: !includeAnalyzed };
|
||||
|
||||
if (scope === 'manga') {
|
||||
if (!mangaId.trim()) {
|
||||
error = 'Enter a manga id.';
|
||||
return;
|
||||
}
|
||||
opts.mangaId = mangaId.trim();
|
||||
} else if (scope === 'chapter') {
|
||||
if (!chapterId.trim()) {
|
||||
error = 'Enter a chapter id.';
|
||||
return;
|
||||
}
|
||||
opts.chapterId = chapterId.trim();
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const r = await reenqueueAnalysis(opts);
|
||||
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis.`;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error =
|
||||
err.status === 503
|
||||
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
|
||||
: err.message;
|
||||
} else {
|
||||
error = 'Failed to enqueue analysis.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mangalord | Admin — Analysis</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="heading">Content analysis</h1>
|
||||
<p class="lede">
|
||||
Queue page images for the AI analysis worker (OCR, auto-tags, scene
|
||||
description, NSFW moderation). Pick a scope and run it.
|
||||
</p>
|
||||
|
||||
<form class="panel" onsubmit={submit} data-testid="admin-analysis-form">
|
||||
<fieldset class="scopes">
|
||||
<legend>Scope</legend>
|
||||
{#each scopeOptions as o (o.value)}
|
||||
<label class="scope-opt" class:active={scope === o.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value={o.value}
|
||||
checked={scope === o.value}
|
||||
onchange={() => (scope = o.value)}
|
||||
data-testid={`admin-analysis-scope-${o.value}`}
|
||||
/>
|
||||
<span class="scope-label">{o.label}</span>
|
||||
<span class="scope-hint">{o.hint}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#if scope === 'manga'}
|
||||
<label class="field">
|
||||
<span>Manga id</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mangaId}
|
||||
placeholder="uuid"
|
||||
data-testid="admin-analysis-manga-id"
|
||||
/>
|
||||
</label>
|
||||
{:else if scope === 'chapter'}
|
||||
<label class="field">
|
||||
<span>Chapter id</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={chapterId}
|
||||
placeholder="uuid"
|
||||
data-testid="admin-analysis-chapter-id"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includeAnalyzed}
|
||||
data-testid="admin-analysis-include-analyzed"
|
||||
/>
|
||||
<span>
|
||||
Include already-analyzed pages
|
||||
<span class="muted">
|
||||
— re-runs analysis on pages that already have results
|
||||
(default off: only un-analyzed pages are queued).
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={busy} data-testid="admin-analysis-submit">
|
||||
{busy ? 'Queueing…' : 'Queue for analysis'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if notice}
|
||||
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.heading {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
max-width: 42rem;
|
||||
}
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
max-width: 42rem;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
background: var(--surface);
|
||||
}
|
||||
fieldset.scopes {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
legend {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.scope-opt {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: var(--space-2);
|
||||
align-items: baseline;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-opt.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft-bg);
|
||||
}
|
||||
.scope-opt input {
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
}
|
||||
.scope-label {
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.scope-hint {
|
||||
grid-column: 2;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.field span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.field input {
|
||||
max-width: 26rem;
|
||||
}
|
||||
.checkbox {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-2);
|
||||
align-items: start;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.checkbox .muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.notice {
|
||||
color: var(--success, #2e7d32);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { updateReadProgress } from '$lib/api/read_progress';
|
||||
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 { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
@@ -118,6 +118,8 @@
|
||||
let actionSheetOpen = $state(false);
|
||||
let collectionsModalOpen = $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
|
||||
// page A can't clobber a fresh load for page B. The user right-
|
||||
@@ -148,13 +150,27 @@
|
||||
activePageId = pageId;
|
||||
contextMenuAnchor = anchor;
|
||||
contextMenuOpen = true;
|
||||
analyzeState = 'idle';
|
||||
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) {
|
||||
if (!session.user) return;
|
||||
activePageId = pageId;
|
||||
actionSheetOpen = true;
|
||||
analyzeState = 'idle';
|
||||
void loadPageSummary(pageId);
|
||||
}
|
||||
|
||||
@@ -1454,6 +1470,9 @@
|
||||
onCopyLink={handleCopyLink}
|
||||
collectionsCount={activePageCollectionCount}
|
||||
tags={activePageTags}
|
||||
canAnalyze={!!session.user?.is_admin}
|
||||
onAnalyzePage={handleAnalyzePage}
|
||||
{analyzeState}
|
||||
/>
|
||||
|
||||
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
||||
|
||||
Reference in New Issue
Block a user