feat: catalogue enrichment — tagline, genres, warnings, quality score, batch covers
Backend (handlers_catalogue.go):
- POST /api/admin/text-gen/tagline — 1-sentence marketing hook
- POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist
- POST /api/admin/text-gen/content-warnings — mature theme detection
- POST /api/admin/text-gen/quality-score — 1–5 description quality rating
- POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one
- POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry
- POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh
Frontend:
- text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete
- image-gen: localStorage style presets (save/apply/delete named prompt templates)
- catalogue-tools: new admin page with batch cover SSE progress + cancel
- admin nav: "Catalogue Tools" link added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,15 +5,18 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const models = data.models as TextModelInfo[];
|
||||
const books = data.books as BookSummary[];
|
||||
// Server data is static per page load — intentional one-time snapshot.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: TextModelInfo[] = data.models ?? [];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
|
||||
// ── Config persistence ───────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v1';
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v2';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
activeTab: 'chapters' | 'description';
|
||||
activeTab: string;
|
||||
chPattern: string;
|
||||
dInstructions: string;
|
||||
}
|
||||
@@ -35,8 +38,8 @@
|
||||
const saved = loadConfig();
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||
type ActiveTab = 'chapters' | 'description';
|
||||
let activeTab = $state<ActiveTab>(saved.activeTab ?? 'chapters');
|
||||
type ActiveTab = 'chapters' | 'description' | 'tagline' | 'genres' | 'warnings' | 'quality';
|
||||
let activeTab = $state<ActiveTab>((saved.activeTab as ActiveTab) ?? 'chapters');
|
||||
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
|
||||
|
||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||
@@ -326,6 +329,142 @@
|
||||
dApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tagline state ─────────────────────────────────────────────────────────────
|
||||
let tAC = makeBookAC();
|
||||
let tSlug = $state('');
|
||||
let tGenerating = $state(false);
|
||||
let tError = $state('');
|
||||
let tResult = $state('');
|
||||
let tUsedModel = $state('');
|
||||
|
||||
let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating);
|
||||
|
||||
function selectTBook(b: BookSummary) { tSlug = b.slug; tAC.inputVal = b.slug; tAC.focused = false; }
|
||||
function onTSlugInput() { tSlug = tAC.inputVal; }
|
||||
|
||||
async function generateTagline() {
|
||||
if (!tCanGenerate) return;
|
||||
tGenerating = true; tError = ''; tResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/tagline', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: tSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; }
|
||||
tResult = body.new_tagline ?? '';
|
||||
tUsedModel = body.model ?? '';
|
||||
} catch { tError = 'Network error.'; } finally { tGenerating = false; }
|
||||
}
|
||||
|
||||
// ── Genres state ──────────────────────────────────────────────────────────────
|
||||
let gAC = makeBookAC();
|
||||
let gSlug = $state('');
|
||||
let gGenerating = $state(false);
|
||||
let gError = $state('');
|
||||
let gCurrent = $state<string[]>([]);
|
||||
let gProposed = $state<string[]>([]);
|
||||
let gEdited = $state<string[]>([]);
|
||||
let gUsedModel = $state('');
|
||||
let gApplying = $state(false);
|
||||
let gApplyError = $state('');
|
||||
let gApplySuccess = $state(false);
|
||||
|
||||
let gCanGenerate = $derived(gSlug.trim().length > 0 && !gGenerating);
|
||||
let gCanApply = $derived(gEdited.length > 0 && !gApplying);
|
||||
|
||||
function selectGBook(b: BookSummary) { gSlug = b.slug; gAC.inputVal = b.slug; gAC.focused = false; }
|
||||
function onGSlugInput() { gSlug = gAC.inputVal; }
|
||||
|
||||
async function generateGenres() {
|
||||
if (!gCanGenerate) return;
|
||||
gGenerating = true; gError = ''; gCurrent = []; gProposed = []; gEdited = []; gApplySuccess = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/genres', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: gSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { gError = body.error ?? `Error ${res.status}`; return; }
|
||||
gCurrent = body.current_genres ?? [];
|
||||
gProposed = body.proposed_genres ?? [];
|
||||
gEdited = [...gProposed];
|
||||
gUsedModel = body.model ?? '';
|
||||
} catch { gError = 'Network error.'; } finally { gGenerating = false; }
|
||||
}
|
||||
|
||||
async function applyGenres() {
|
||||
if (!gCanApply) return;
|
||||
gApplying = true; gApplyError = ''; gApplySuccess = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/genres/apply', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: gSlug.trim(), genres: gEdited.filter(Boolean) })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { gApplyError = body.error ?? `Error ${res.status}`; return; }
|
||||
gApplySuccess = true;
|
||||
} catch { gApplyError = 'Network error.'; } finally { gApplying = false; }
|
||||
}
|
||||
|
||||
// ── Content warnings state ────────────────────────────────────────────────────
|
||||
let wAC = makeBookAC();
|
||||
let wSlug = $state('');
|
||||
let wGenerating = $state(false);
|
||||
let wError = $state('');
|
||||
let wWarnings = $state<string[]>([]);
|
||||
let wUsedModel = $state('');
|
||||
|
||||
let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating);
|
||||
|
||||
function selectWBook(b: BookSummary) { wSlug = b.slug; wAC.inputVal = b.slug; wAC.focused = false; }
|
||||
function onWSlugInput() { wSlug = wAC.inputVal; }
|
||||
|
||||
async function generateWarnings() {
|
||||
if (!wCanGenerate) return;
|
||||
wGenerating = true; wError = ''; wWarnings = [];
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/content-warnings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: wSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { wError = body.error ?? `Error ${res.status}`; return; }
|
||||
wWarnings = body.warnings ?? [];
|
||||
wUsedModel = body.model ?? '';
|
||||
} catch { wError = 'Network error.'; } finally { wGenerating = false; }
|
||||
}
|
||||
|
||||
// ── Quality score state ───────────────────────────────────────────────────────
|
||||
let qAC = makeBookAC();
|
||||
let qSlug = $state('');
|
||||
let qGenerating = $state(false);
|
||||
let qError = $state('');
|
||||
let qScore = $state(0);
|
||||
let qFeedback = $state('');
|
||||
let qUsedModel = $state('');
|
||||
|
||||
let qCanGenerate = $derived(qSlug.trim().length > 0 && !qGenerating);
|
||||
|
||||
function selectQBook(b: BookSummary) { qSlug = b.slug; qAC.inputVal = b.slug; qAC.focused = false; }
|
||||
function onQSlugInput() { qSlug = qAC.inputVal; }
|
||||
|
||||
async function generateQualityScore() {
|
||||
if (!qCanGenerate) return;
|
||||
qGenerating = true; qError = ''; qScore = 0; qFeedback = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/quality-score', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: qSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { qError = body.error ?? `Error ${res.status}`; return; }
|
||||
qScore = body.score ?? 0;
|
||||
qFeedback = body.feedback ?? '';
|
||||
qUsedModel = body.model ?? '';
|
||||
} catch { qError = 'Network error.'; } finally { qGenerating = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -361,16 +500,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab toggle -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each (['chapters', 'description'] as const) as t}
|
||||
<div class="flex flex-wrap gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each ([
|
||||
['chapters', 'Chapter Names'],
|
||||
['description', 'Description'],
|
||||
['tagline', 'Tagline'],
|
||||
['genres', 'Genres'],
|
||||
['warnings', 'Warnings'],
|
||||
['quality', 'Quality'],
|
||||
] as const) as [t, label]}
|
||||
<button
|
||||
onclick={() => (activeTab = t)}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === t
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{t === 'chapters' ? 'Chapter Names' : 'Description'}
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -706,4 +852,305 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Tagline panel ──────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'tagline'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="t-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="t-slug" type="text" bind:value={tAC.inputVal} oninput={onTSlugInput}
|
||||
onfocus={() => (tAC.focused = true)} onblur={() => setTimeout(() => { tAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if tAC.focused && tAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each tAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={tSlug === b.slug} onmousedown={() => selectTBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateTagline} disabled={!tCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if tGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Generating…
|
||||
{:else}Generate tagline{/if}
|
||||
</button>
|
||||
{#if tError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{tError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if tResult}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-brand)/40 rounded-xl p-4">
|
||||
<p class="text-base italic text-(--color-text)">{tResult}</p>
|
||||
</div>
|
||||
<button onclick={() => { tResult = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if tGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'genres'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="g-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="g-slug" type="text" bind:value={gAC.inputVal} oninput={onGSlugInput}
|
||||
onfocus={() => (gAC.focused = true)} onblur={() => setTimeout(() => { gAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if gAC.focused && gAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each gAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={gSlug === b.slug} onmousedown={() => selectGBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateGenres} disabled={!gCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if gGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Generating…
|
||||
{:else}Suggest genres{/if}
|
||||
</button>
|
||||
{#if gError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gError}</p>{/if}
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{#if gProposed.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#if gCurrent.length > 0}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">Current genres</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gCurrent as g}
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">
|
||||
Proposed{#if gUsedModel}<span class="normal-case font-normal"> · {gUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gEdited as g, i}
|
||||
<input type="text" bind:value={gEdited[i]}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||||
{/each}
|
||||
<button onclick={() => { gEdited = [...gEdited, '']; }}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors">+ add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick={applyGenres} disabled={!gCanApply}
|
||||
class="flex-1 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{gApplying ? 'Saving…' : gApplySuccess ? 'Saved ✓' : 'Apply genres'}
|
||||
</button>
|
||||
<button onclick={() => { gProposed = []; gEdited = []; gApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm hover:text-(--color-text) transition-colors border border-(--color-border)">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{#if gApplyError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gApplyError}</p>{/if}
|
||||
{#if gApplySuccess}<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Genres saved successfully.</p>{/if}
|
||||
</div>
|
||||
{:else if gGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-32">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-32">
|
||||
<p class="text-sm text-(--color-muted)">Genre suggestions will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Content warnings panel ─────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'warnings'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="w-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="w-slug" type="text" bind:value={wAC.inputVal} oninput={onWSlugInput}
|
||||
onfocus={() => (wAC.focused = true)} onblur={() => setTimeout(() => { wAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if wAC.focused && wAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each wAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={wSlug === b.slug} onmousedown={() => selectWBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateWarnings} disabled={!wCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if wGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Detecting…
|
||||
{:else}Detect content warnings{/if}
|
||||
</button>
|
||||
{#if wError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{wError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if wWarnings.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each wWarnings as w}
|
||||
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={() => { wWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if wWarnings.length === 0 && wUsedModel}
|
||||
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
|
||||
<p class="text-sm text-green-400">No content warnings detected.</p>
|
||||
</div>
|
||||
{:else if wGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'quality'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="q-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="q-slug" type="text" bind:value={qAC.inputVal} oninput={onQSlugInput}
|
||||
onfocus={() => (qAC.focused = true)} onblur={() => setTimeout(() => { qAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if qAC.focused && qAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each qAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={qSlug === b.slug} onmousedown={() => selectQBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateQualityScore} disabled={!qCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if qGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Scoring…
|
||||
{:else}Score description quality{/if}
|
||||
</button>
|
||||
{#if qError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{qError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if qScore > 0}
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Quality score{#if qUsedModel}<span class="normal-case font-normal"> · {qUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-(--color-brand)">{qScore}</span>
|
||||
<div class="flex gap-1">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<span class="text-xl {star <= qScore ? 'text-amber-400' : 'text-(--color-border)'}">★</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if qFeedback}
|
||||
<p class="text-sm text-(--color-muted)">{qFeedback}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={() => { qScore = 0; qFeedback = ''; qUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if qGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Quality score will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user