The Analysis section now reflects worker progress in real time: - Opens an EventSource on the live stream while mounted; a "Live" pill reflects connection state and reconnects on drop (probes after repeated failures so a lost session logs out). - Applies events incrementally: enqueued marks in-scope loaded pages queued; started flips a chip to a pulsing "analyzing"; completed flips it green and live-bumps the manga + chapter coverage badges (guarded so no double count); failed flips it red. A compact activity ticker shows the last few events. - Server numbers stay authoritative: re-loading the overview or a page grid clears the matching live overrides. API client: analysisStatusStreamUrl() + AnalysisEvent type. Tests: vitest for the stream URL; Playwright asserts SSE frames flow into the ticker (existing coverage/drill/queue specs get a default quiet stream). svelte-check + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
996 lines
33 KiB
Svelte
996 lines
33 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { ApiError } from '$lib/api/client';
|
|
import {
|
|
reenqueueAnalysis,
|
|
analyzePage,
|
|
getAnalysisMangaCoverage,
|
|
getAnalysisChapterCoverage,
|
|
getAnalysisChapterPages,
|
|
getAnalysisPageDetail,
|
|
analysisStatusStreamUrl,
|
|
type MangaCoverage,
|
|
type ChapterCoverage,
|
|
type PageStatusItem,
|
|
type PageAnalysisDetail,
|
|
type ReenqueueAnalysisOptions,
|
|
type AnalysisEvent
|
|
} from '$lib/api/admin';
|
|
import { chapterLabel } from '$lib/api/chapters';
|
|
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
|
|
const LIMIT = 25;
|
|
|
|
// Global enqueue toggle.
|
|
let includeAnalyzed = $state(false);
|
|
|
|
// Coverage overview / search.
|
|
let query = $state('');
|
|
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);
|
|
|
|
// Drill-down state.
|
|
let expandedManga = $state<string | null>(null);
|
|
let chapters = $state<Record<string, 'loading' | ChapterCoverage[]>>({});
|
|
let expandedChapter = $state<string | null>(null);
|
|
let pages = $state<Record<string, 'loading' | PageStatusItem[]>>({});
|
|
|
|
// 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 notice = $state<string | null>(null);
|
|
let error = $state<string | null>(null);
|
|
|
|
// --- Live status (SSE) ---
|
|
type LiveStatus = 'analyzing' | 'done' | 'failed' | 'queued';
|
|
// Per-page override applied on top of the fetched grid status.
|
|
let liveStatus = $state<Record<string, LiveStatus>>({});
|
|
// Pages we've already live-counted into coverage, so a repeat
|
|
// `completed` can't double-bump a badge.
|
|
let counted = new Set<string>();
|
|
let ticker = $state<{ id: number; text: string }[]>([]);
|
|
let tickerSeq = 0;
|
|
let live = $state(false);
|
|
let source: EventSource | null = null;
|
|
let sseErrors = 0;
|
|
|
|
onMount(() => {
|
|
void loadCoverage(true);
|
|
openStream();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
source?.close();
|
|
source = null;
|
|
});
|
|
|
|
function pushTicker(text: string) {
|
|
ticker = [{ id: ++tickerSeq, text }, ...ticker].slice(0, 8);
|
|
}
|
|
|
|
/** Effective chip status: live override wins over the fetched value. */
|
|
function chipStatus(p: PageStatusItem): string {
|
|
return liveStatus[p.page_id] ?? p.status;
|
|
}
|
|
|
|
/** mangaId that owns a loaded chapter, via the chapters cache. */
|
|
function mangaOfChapter(chapterId: string): string | null {
|
|
for (const [mid, val] of Object.entries(chapters)) {
|
|
if (Array.isArray(val) && val.some((c) => c.chapter_id === chapterId)) {
|
|
return mid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function bumpCoverage(mangaId: string, chapterId: string) {
|
|
mangas = mangas.map((m) =>
|
|
m.manga_id === mangaId
|
|
? {
|
|
...m,
|
|
analyzed_pages: Math.min(m.total_pages, m.analyzed_pages + 1)
|
|
}
|
|
: m
|
|
);
|
|
const chs = chapters[mangaId];
|
|
if (Array.isArray(chs)) {
|
|
chapters = {
|
|
...chapters,
|
|
[mangaId]: chs.map((c) =>
|
|
c.chapter_id === chapterId
|
|
? {
|
|
...c,
|
|
analyzed_pages: Math.min(
|
|
c.total_pages,
|
|
c.analyzed_pages + 1
|
|
)
|
|
}
|
|
: c
|
|
)
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Mark loaded, in-scope, not-yet-done pages as queued. */
|
|
function markQueued(ev: Extract<AnalysisEvent, { kind: 'enqueued' }>) {
|
|
const next = { ...liveStatus };
|
|
for (const [chapterId, val] of Object.entries(pages)) {
|
|
if (!Array.isArray(val)) continue;
|
|
const inScope =
|
|
ev.chapter_id != null
|
|
? chapterId === ev.chapter_id
|
|
: ev.manga_id != null
|
|
? mangaOfChapter(chapterId) === ev.manga_id
|
|
: true; // whole library
|
|
if (!inScope) continue;
|
|
for (const p of val) {
|
|
const cur: string = next[p.page_id] ?? p.status;
|
|
if (cur === 'none' || cur === 'failed') next[p.page_id] = 'queued';
|
|
}
|
|
}
|
|
liveStatus = next;
|
|
}
|
|
|
|
function applyEvent(ev: AnalysisEvent) {
|
|
if (ev.kind === 'enqueued') {
|
|
const scope = ev.chapter_id
|
|
? ' (chapter)'
|
|
: ev.manga_id
|
|
? ' (manga)'
|
|
: ' (library)';
|
|
pushTicker(`Queued ${ev.count} page${ev.count === 1 ? '' : 's'}${scope}`);
|
|
markQueued(ev);
|
|
return;
|
|
}
|
|
if (ev.kind === 'started') {
|
|
liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
|
|
pushTicker(`Analyzing page ${ev.page_number}`);
|
|
} else if (ev.kind === 'completed') {
|
|
liveStatus = { ...liveStatus, [ev.page_id]: 'done' };
|
|
if (!counted.has(ev.page_id)) {
|
|
counted.add(ev.page_id);
|
|
bumpCoverage(ev.manga_id, ev.chapter_id);
|
|
}
|
|
pushTicker(`✓ Analyzed page ${ev.page_number}`);
|
|
} else if (ev.kind === 'failed') {
|
|
liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
|
|
pushTicker(`✗ Failed page ${ev.page_number}`);
|
|
}
|
|
}
|
|
|
|
function openStream() {
|
|
if (source) return;
|
|
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
|
|
es.addEventListener('analysis', (e) => {
|
|
try {
|
|
applyEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
|
|
live = true;
|
|
sseErrors = 0;
|
|
} catch {
|
|
// ignore a malformed frame
|
|
}
|
|
});
|
|
// Dropped frames — resync the overview from the server.
|
|
es.addEventListener('lagged', () => void loadCoverage(true));
|
|
es.onopen = () => {
|
|
live = true;
|
|
sseErrors = 0;
|
|
};
|
|
es.onerror = () => {
|
|
live = false;
|
|
sseErrors += 1;
|
|
// EventSource hides the status code; after several failures,
|
|
// probe a normal admin call so the global on401 hook can fire.
|
|
if (sseErrors >= 4) {
|
|
sseErrors = 0;
|
|
getAnalysisMangaCoverage({ limit: 1 }).catch(() => {});
|
|
}
|
|
};
|
|
source = es;
|
|
}
|
|
|
|
function onSearchInput() {
|
|
if (searchTimer) clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(() => loadCoverage(true), 300);
|
|
}
|
|
|
|
async function loadCoverage(reset: boolean) {
|
|
if (reset) {
|
|
offset = 0;
|
|
loading = true;
|
|
// Collapse open rows so stale chapter/page state doesn't linger.
|
|
expandedManga = null;
|
|
expandedChapter = null;
|
|
// Server numbers are authoritative again — drop live overrides.
|
|
liveStatus = {};
|
|
counted = new Set();
|
|
} else {
|
|
loadingMore = true;
|
|
}
|
|
error = null;
|
|
try {
|
|
const r = await getAnalysisMangaCoverage({
|
|
search: query.trim() || undefined,
|
|
limit: LIMIT,
|
|
offset
|
|
});
|
|
mangas = reset ? r.items : [...mangas, ...r.items];
|
|
total = r.page.total ?? mangas.length;
|
|
} catch (e) {
|
|
error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
|
|
} finally {
|
|
loading = false;
|
|
loadingMore = false;
|
|
}
|
|
}
|
|
|
|
async function loadMore() {
|
|
offset += LIMIT;
|
|
await loadCoverage(false);
|
|
}
|
|
|
|
async function toggleManga(id: string) {
|
|
if (expandedManga === id) {
|
|
expandedManga = null;
|
|
return;
|
|
}
|
|
expandedManga = id;
|
|
expandedChapter = null;
|
|
if (!chapters[id]) {
|
|
chapters[id] = 'loading';
|
|
try {
|
|
chapters[id] = await getAnalysisChapterCoverage(id);
|
|
} catch (e) {
|
|
delete chapters[id];
|
|
expandedManga = null;
|
|
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 {
|
|
const fresh = await getAnalysisChapterPages(id);
|
|
// Backend status is authoritative for just-fetched pages; drop
|
|
// any live overrides for them so the grid reflects the server.
|
|
const next = { ...liveStatus };
|
|
for (const p of fresh) delete next[p.page_id];
|
|
liveStatus = next;
|
|
pages[id] = fresh;
|
|
} 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(
|
|
key: string,
|
|
scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
|
|
label: string
|
|
) {
|
|
if (busyKey) return;
|
|
busyKey = key;
|
|
notice = null;
|
|
error = null;
|
|
try {
|
|
const r = await reenqueueAnalysis({
|
|
...scope,
|
|
onlyUnanalyzed: !includeAnalyzed
|
|
});
|
|
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) {
|
|
if (e instanceof ApiError) {
|
|
error =
|
|
e.status === 503
|
|
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
|
|
: e.message;
|
|
} else {
|
|
error = 'Failed to enqueue analysis.';
|
|
}
|
|
} finally {
|
|
busyKey = null;
|
|
}
|
|
}
|
|
|
|
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
|
|
const enqueueManga = (m: MangaCoverage) =>
|
|
enqueue(`manga:${m.manga_id}`, { mangaId: m.manga_id }, m.title);
|
|
const enqueueChapter = (m: MangaCoverage, c: ChapterCoverage) =>
|
|
enqueue(
|
|
`chapter:${c.chapter_id}`,
|
|
{ chapterId: c.chapter_id },
|
|
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
|
|
);
|
|
|
|
function fmtDate(iso: string | null): string {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Mangalord | Admin — Analysis</title>
|
|
</svelte:head>
|
|
|
|
<div class="title-row">
|
|
<h1 class="heading">Content analysis</h1>
|
|
<span
|
|
class="live-pill"
|
|
class:on={live}
|
|
data-testid="admin-analysis-live"
|
|
title={live ? 'Live updates connected' : 'Reconnecting…'}
|
|
>
|
|
<span class="live-dot"></span>
|
|
{live ? 'Live' : 'Reconnecting…'}
|
|
</span>
|
|
</div>
|
|
<p class="lede">
|
|
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
|
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
|
page's result. Updates stream live as pages are queued and processed.
|
|
</p>
|
|
|
|
{#if ticker.length > 0}
|
|
<ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
|
|
{#each ticker as t (t.id)}
|
|
<li>{t.text}</li>
|
|
{/each}
|
|
</ul>
|
|
{/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). Applies to every "Queue" action.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div class="row library-row">
|
|
<span class="row-title">Whole library</span>
|
|
<button
|
|
type="button"
|
|
disabled={busyKey !== null}
|
|
onclick={enqueueLibrary}
|
|
data-testid="admin-analysis-enqueue-library"
|
|
>
|
|
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
|
|
</button>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<span>Search</span>
|
|
<input
|
|
type="text"
|
|
bind:value={query}
|
|
oninput={onSearchInput}
|
|
placeholder="Filter mangas by title…"
|
|
data-testid="admin-analysis-search"
|
|
/>
|
|
</label>
|
|
|
|
{#if loading}
|
|
<p class="muted" data-testid="admin-analysis-loading">Loading coverage…</p>
|
|
{:else if mangas.length === 0}
|
|
<p class="muted" data-testid="admin-analysis-empty">
|
|
No mangas with pages{query.trim() ? ` match "${query.trim()}"` : ''}.
|
|
</p>
|
|
{: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 expandedManga === m.manga_id}
|
|
{#if chapters[m.manga_id] === 'loading'}
|
|
<p class="muted indent">Loading chapters…</p>
|
|
{:else if Array.isArray(chapters[m.manga_id])}
|
|
{@const chs = chapters[m.manga_id] as ChapterCoverage[]}
|
|
<ul class="chapters">
|
|
{#each chs as c (c.chapter_id)}
|
|
<li
|
|
class="chapter"
|
|
data-testid={`admin-analysis-chapter-${c.chapter_id}`}
|
|
>
|
|
<div class="row">
|
|
<button
|
|
type="button"
|
|
class="disclosure"
|
|
aria-expanded={expandedChapter === c.chapter_id}
|
|
onclick={() => toggleChapter(c.chapter_id)}
|
|
data-testid={`admin-analysis-expand-chapter-${c.chapter_id}`}
|
|
>
|
|
<span
|
|
class="caret"
|
|
class:open={expandedChapter === c.chapter_id}
|
|
>▸</span
|
|
>
|
|
<span class="row-main">
|
|
<span class="chapter-label">
|
|
{chapterLabel({
|
|
number: c.number,
|
|
title: c.title
|
|
})}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
<CoverageBadge
|
|
analyzed={c.analyzed_pages}
|
|
total={c.total_pages}
|
|
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}`}
|
|
>
|
|
{#each ps as p (p.page_id)}
|
|
<button
|
|
type="button"
|
|
class="page-chip {chipStatus(p)}"
|
|
title={`Page ${p.page_number} — ${chipStatus(p)}`}
|
|
onclick={() => openDetail(p.page_id)}
|
|
data-testid={`admin-analysis-page-${p.page_id}`}
|
|
data-status={chipStatus(p)}
|
|
>
|
|
{p.page_number}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<p class="legend">
|
|
<span class="dot done"></span> analyzed
|
|
<span class="dot analyzing"></span> analyzing
|
|
<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>
|
|
{/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}
|
|
|
|
{#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}
|
|
|
|
<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>
|
|
.title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
.heading {
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
.title-row .heading {
|
|
margin-bottom: 0;
|
|
}
|
|
.live-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: var(--font-xs);
|
|
font-weight: var(--weight-semibold);
|
|
padding: 2px var(--space-2);
|
|
border-radius: var(--radius-pill);
|
|
background: var(--surface-elevated);
|
|
color: var(--text-muted);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.live-pill.on {
|
|
color: #2e7d32;
|
|
border-color: color-mix(in srgb, #2e7d32 40%, transparent);
|
|
}
|
|
.live-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
.live-pill.on .live-dot {
|
|
animation: pulse 1.6s ease-in-out infinite;
|
|
}
|
|
.ticker {
|
|
list-style: none;
|
|
margin: 0 0 var(--space-3);
|
|
padding: var(--space-2) var(--space-3);
|
|
max-width: 44rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface);
|
|
font-size: var(--font-sm);
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
max-height: 9rem;
|
|
overflow: hidden;
|
|
}
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.35;
|
|
}
|
|
}
|
|
.lede {
|
|
color: var(--text-muted);
|
|
margin-bottom: var(--space-4);
|
|
max-width: 44rem;
|
|
}
|
|
.checkbox {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: var(--space-2);
|
|
align-items: start;
|
|
font-size: var(--font-sm);
|
|
margin-bottom: var(--space-3);
|
|
max-width: 44rem;
|
|
}
|
|
.muted {
|
|
color: var(--text-muted);
|
|
}
|
|
.indent {
|
|
margin-left: var(--space-5);
|
|
}
|
|
.library-row {
|
|
max-width: 44rem;
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
margin-bottom: var(--space-3);
|
|
max-width: 44rem;
|
|
}
|
|
.field > span {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
.row-main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
flex: 1;
|
|
text-align: left;
|
|
}
|
|
.row-title,
|
|
.chapter-label {
|
|
font-weight: var(--weight-semibold);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.results {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
max-width: 44rem;
|
|
}
|
|
.manga {
|
|
border-top: 1px solid var(--border);
|
|
padding: var(--space-2) 0;
|
|
}
|
|
.disclosure {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
color: inherit;
|
|
}
|
|
.caret {
|
|
transition: transform 0.12s ease;
|
|
color: var(--text-muted);
|
|
}
|
|
.caret.open {
|
|
transform: rotate(90deg);
|
|
}
|
|
.chapters {
|
|
list-style: none;
|
|
margin: var(--space-2) 0 0 var(--space-4);
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
.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.analyzing {
|
|
background: color-mix(in srgb, #d4762a 22%, transparent);
|
|
border-color: color-mix(in srgb, #d4762a 55%, transparent);
|
|
color: #b85e1a;
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
}
|
|
.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;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin: var(--space-2) 0 0 var(--space-5);
|
|
font-size: var(--font-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
.dot {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
margin-right: 2px;
|
|
vertical-align: middle;
|
|
}
|
|
.dot.done {
|
|
background: #2e7d32;
|
|
}
|
|
.dot.queued {
|
|
background: #2563eb;
|
|
}
|
|
.dot.analyzing {
|
|
background: #d4762a;
|
|
}
|
|
.dot.failed {
|
|
background: var(--danger);
|
|
}
|
|
.dot.none {
|
|
background: var(--surface-elevated);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.load-more {
|
|
margin-top: var(--space-3);
|
|
}
|
|
.notice {
|
|
color: var(--success, #2e7d32);
|
|
max-width: 44rem;
|
|
}
|
|
.error {
|
|
color: var(--danger);
|
|
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>
|