feat(analysis): admin analysis section uses manga search + chapter picker
Replaces the raw manga-id / chapter-id UUID inputs with a real picker: - Search a manga (debounced, reuses listAdminMangas) → result rows show title + chapter count; "Queue manga" enqueues the whole manga. - Expand a result to load its chapters (listAdminChapters) and "Queue chapter" enqueues a single chapter. - Keeps the "Queue all (whole library)" action and a single global "include already-analyzed" toggle applied to every action; per-action busy state + a result message naming what was queued. Tests: rewrote the Playwright spec to drive the search → manga/chapter flow (whole library, search+queue manga, expand+queue chapter with include-analyzed). svelte-check + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
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.
|
||||
// E2E for the admin Analysis section: enqueue the whole library, or
|
||||
// search a manga and enqueue it / one of its chapters, with the
|
||||
// include-already-analyzed toggle. Fully mocked.
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
||||
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
||||
|
||||
const adminUser = {
|
||||
id: 'u11111111-1111-1111-1111-111111111111',
|
||||
username: 'admin',
|
||||
@@ -19,6 +23,29 @@ const systemStats = {
|
||||
alerts: []
|
||||
};
|
||||
|
||||
const mangaRow = {
|
||||
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 = {
|
||||
id: chapterId,
|
||||
manga_id: mangaId,
|
||||
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) {
|
||||
@@ -57,6 +84,28 @@ async function mockAdmin(page: Page, captured: Captured) {
|
||||
body: JSON.stringify(systemStats)
|
||||
})
|
||||
);
|
||||
// Manga search. Registered before the chapters route so the more
|
||||
// specific chapters glob (added later) wins for chapter URLs.
|
||||
await page.route('**/api/v1/admin/mangas**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [mangaRow],
|
||||
page: { limit: 20, offset: 0, total: 1 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/mangas/*/chapters**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [chapterRow],
|
||||
page: { limit: 500, offset: 0, total: 1 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
|
||||
captured.body = JSON.parse(route.request().postData() ?? '{}');
|
||||
return route.fulfill({
|
||||
@@ -68,16 +117,13 @@ async function mockAdmin(page: Page, captured: Captured) {
|
||||
}
|
||||
|
||||
test.describe('/admin/analysis', () => {
|
||||
test('default whole-library re-enqueue posts only_unanalyzed=true', async ({
|
||||
page
|
||||
}) => {
|
||||
test('queue the whole library 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 page.getByTestId('admin-analysis-enqueue-library').click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||
'Enqueued 12 pages'
|
||||
@@ -85,40 +131,41 @@ test.describe('/admin/analysis', () => {
|
||||
expect(captured.body).toEqual({ only_unanalyzed: true });
|
||||
});
|
||||
|
||||
test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', async ({
|
||||
page
|
||||
}) => {
|
||||
test('search a manga and queue it', 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-search').fill('ber');
|
||||
await expect(
|
||||
page.getByTestId(`admin-analysis-manga-${mangaId}`)
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId(`admin-analysis-enqueue-manga-${mangaId}`).click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||
'Berserk'
|
||||
);
|
||||
expect(captured.body).toEqual({ only_unanalyzed: true, manga_id: mangaId });
|
||||
});
|
||||
|
||||
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-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 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,
|
||||
manga_id: 'a9999999-9999-9999-9999-999999999999'
|
||||
chapter_id: chapterId
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.73.0",
|
||||
"version": "0.74.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,76 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { reenqueueAnalysis } from '$lib/api/admin';
|
||||
import {
|
||||
reenqueueAnalysis,
|
||||
listAdminMangas,
|
||||
listAdminChapters,
|
||||
type AdminMangaRow,
|
||||
type AdminChapterRow,
|
||||
type ReenqueueAnalysisOptions
|
||||
} from '$lib/api/admin';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
|
||||
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.
|
||||
// One global toggle applied to whichever enqueue action runs.
|
||||
let includeAnalyzed = $state(false);
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// Manga search.
|
||||
let query = $state('');
|
||||
let mangas = $state<AdminMangaRow[]>([]);
|
||||
let searching = $state(false);
|
||||
let searched = $state(false);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Per-manga chapter expansion: id → loading | rows.
|
||||
let expandedId = $state<string | null>(null);
|
||||
let chapters = $state<Record<string, 'loading' | AdminChapterRow[]>>({});
|
||||
|
||||
// In-flight action key ('library' | `manga:<id>` | `chapter:<id>`).
|
||||
let busyKey = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let error = $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.' }
|
||||
];
|
||||
function onSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runSearch, 300);
|
||||
}
|
||||
|
||||
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();
|
||||
async function runSearch() {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
mangas = [];
|
||||
searched = false;
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
searching = true;
|
||||
error = null;
|
||||
try {
|
||||
const r = await reenqueueAnalysis(opts);
|
||||
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis.`;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const r = await listAdminMangas({ search: q, limit: 20 });
|
||||
mangas = r.items;
|
||||
searched = true;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Search failed.';
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleChapters(mangaId: string) {
|
||||
if (expandedId === mangaId) {
|
||||
expandedId = null;
|
||||
return;
|
||||
}
|
||||
expandedId = mangaId;
|
||||
if (!chapters[mangaId]) {
|
||||
chapters[mangaId] = 'loading';
|
||||
try {
|
||||
const r = await listAdminChapters(mangaId, { limit: 500 });
|
||||
chapters[mangaId] = r.items;
|
||||
} catch (e) {
|
||||
delete chapters[mangaId];
|
||||
expandedId = null;
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}).`;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
error =
|
||||
err.status === 503
|
||||
e.status === 503
|
||||
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
|
||||
: err.message;
|
||||
: e.message;
|
||||
} else {
|
||||
error = 'Failed to enqueue analysis.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
busyKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
|
||||
const enqueueManga = (m: AdminMangaRow) =>
|
||||
enqueue(`manga:${m.id}`, { mangaId: m.id }, m.title);
|
||||
const enqueueChapter = (m: AdminMangaRow, c: AdminChapterRow) =>
|
||||
enqueue(
|
||||
`chapter:${c.id}`,
|
||||
{ chapterId: c.id },
|
||||
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -80,78 +120,147 @@
|
||||
<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.
|
||||
description, NSFW moderation).
|
||||
</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>
|
||||
<label class="checkbox" data-testid="admin-analysis-include-analyzed-wrap">
|
||||
<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). Applies to every action
|
||||
below.
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={busy} data-testid="admin-analysis-submit">
|
||||
{busy ? 'Queueing…' : 'Queue for analysis'}
|
||||
<section class="panel" aria-label="Whole library">
|
||||
<div class="row">
|
||||
<div class="row-main">
|
||||
<span class="row-title">Whole library</span>
|
||||
<span class="row-sub">Every page of every chapter of every manga.</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyKey !== null}
|
||||
onclick={enqueueLibrary}
|
||||
data-testid="admin-analysis-enqueue-library"
|
||||
>
|
||||
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if notice}
|
||||
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
|
||||
<section class="panel" aria-label="Search a manga">
|
||||
<label class="field">
|
||||
<span>Search a manga to queue it or one of its chapters</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Title or author…"
|
||||
data-testid="admin-analysis-search"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if searching}
|
||||
<p class="muted" data-testid="admin-analysis-searching">Searching…</p>
|
||||
{:else if searched && mangas.length === 0}
|
||||
<p class="muted" data-testid="admin-analysis-no-results">
|
||||
No mangas match "{query.trim()}".
|
||||
</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
|
||||
|
||||
{#if mangas.length > 0}
|
||||
<ul class="results" data-testid="admin-analysis-results">
|
||||
{#each mangas as m (m.id)}
|
||||
<li class="manga" data-testid={`admin-analysis-manga-${m.id}`}>
|
||||
<div class="row">
|
||||
<button
|
||||
type="button"
|
||||
class="disclosure"
|
||||
aria-expanded={expandedId === m.id}
|
||||
onclick={() => toggleChapters(m.id)}
|
||||
data-testid={`admin-analysis-expand-${m.id}`}
|
||||
>
|
||||
<span class="caret" class:open={expandedId === m.id}>▸</span>
|
||||
<span class="row-main">
|
||||
<span class="row-title">{m.title}</span>
|
||||
<span class="row-sub">
|
||||
{m.chapter_count} chapter{m.chapter_count === 1
|
||||
? ''
|
||||
: '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">
|
||||
{chapterLabel({
|
||||
number: c.number,
|
||||
title: c.title
|
||||
})}
|
||||
<span class="row-sub">
|
||||
{c.page_count} page{c.page_count === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyKey !== null}
|
||||
onclick={() => enqueueChapter(m, c)}
|
||||
data-testid={`admin-analysis-enqueue-chapter-${c.id}`}
|
||||
>
|
||||
{busyKey === `chapter:${c.id}`
|
||||
? 'Queueing…'
|
||||
: 'Queue chapter'}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#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}
|
||||
|
||||
<style>
|
||||
.heading {
|
||||
@@ -162,85 +271,115 @@
|
||||
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);
|
||||
margin-bottom: var(--space-4);
|
||||
max-width: 42rem;
|
||||
}
|
||||
.checkbox .muted {
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.actions {
|
||||
.panel {
|
||||
max-width: 46rem;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
background: var(--surface);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.field > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.row-title {
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.results {
|
||||
list-style: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.chapter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.chapter-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chapters-loading {
|
||||
margin: var(--space-2) 0 0 var(--space-4);
|
||||
}
|
||||
.notice {
|
||||
color: var(--success, #2e7d32);
|
||||
max-width: 46rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
max-width: 46rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user