feat: enhance admin panel on book page — prompts, img2img, SSE chapter names
Some checks failed
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Failing after 35s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 31s
Release / Docker / backend (push) Successful in 2m21s
Release / Docker / runner (push) Successful in 2m23s
Release / Gitea Release (push) Has been skipped

- Cover generation: editable prompt (pre-filled from title+summary),
  img2img toggle to use existing cover as reference, Full editor link
- Chapter cover: editable prompt textarea
- Description: instructions input field, Full editor link
- Chapter names: editable pattern field, SSE streaming with live batch
  progress, inline-editable title proposals, batch warning display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-05 00:45:12 +05:00
parent ad2d1a2603
commit 40151f2f33

View File

@@ -138,6 +138,21 @@
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
let coverPromptOpen = $state(false);
function buildCoverPrompt(): string {
const title = data.book?.title ?? '';
const summary = data.book?.summary ?? '';
const excerpt = summary.length > 200 ? summary.slice(0, 200) + '…' : summary;
return `Book cover art for "${title}"${excerpt ? ` — ${excerpt}` : ''}. Epic scene with dramatic lighting, professional book cover, highly detailed, 4K.`;
}
let coverPrompt = $state('');
let coverUseAsRef = $state(false);
$effect(() => {
if (coverPromptOpen && !coverPrompt) coverPrompt = buildCoverPrompt();
});
async function generateCover() {
const slug = data.book?.slug;
@@ -145,12 +160,34 @@
coverGenerating = true;
coverPreview = null;
coverResult = '';
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
try {
const res = await fetch('/api/admin/image-gen', {
let res: Response;
if (coverUseAsRef && data.book?.cover) {
try {
const imgRes = await fetch(data.book.cover);
const blob = await imgRes.blob();
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
const file = new File([blob], `${slug}-cover-ref.${ext}`, { type: blob.type });
const fd = new FormData();
fd.append('json', JSON.stringify({ slug, type: 'cover', prompt: promptToUse, strength: 0.65 }));
fd.append('reference', file);
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
} catch {
// Fall back to text-only if cover fetch fails
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
@@ -195,6 +232,7 @@
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'error' | ''>('');
let chapterCoverPrompt = $state('');
async function generateChapterCover() {
const slug = data.book?.slug;
@@ -204,11 +242,12 @@
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: promptToUse })
});
if (res.ok) {
const d = await res.json();
@@ -228,6 +267,7 @@
let descPreview = $state('');
let descApplying = $state(false);
let descResult = $state<'applied' | 'error' | ''>('');
let descInstructions = $state('');
async function generateDesc() {
const slug = data.book?.slug;
@@ -239,7 +279,7 @@
const res = await fetch('/api/admin/text-gen/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
body: JSON.stringify({ slug, instructions: descInstructions.trim() || undefined })
});
if (res.ok) {
const d = await res.json();
@@ -281,9 +321,12 @@
// ── Admin: chapter names generation ───────────────────────────────────────
let chapNamesGenerating = $state(false);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
let chapNamesApplying = $state(false);
let chapNamesResult = $state<'applied' | 'error' | ''>('');
let chapNamesPattern = $state('Chapter {n}: {scene}');
let chapNamesBatchProgress = $state('');
let chapNamesBatchWarnings = $state<string[]>([]);
async function generateChapNames() {
const slug = data.book?.slug;
@@ -291,18 +334,46 @@
chapNamesGenerating = true;
chapNamesPreview = [];
chapNamesResult = '';
chapNamesBatchProgress = '';
chapNamesBatchWarnings = [];
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
});
if (res.ok) {
const d = await res.json();
chapNamesPreview = d.chapters ?? [];
} else {
if (!res.ok) {
chapNamesResult = 'error';
return;
}
// SSE streaming response
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
outer: while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: { batch?: number; total_batches?: number; chapters_done?: number; total_chapters?: number; chapters?: { number: number; old_title: string; new_title: string }[]; error?: string; done?: boolean };
try { evt = JSON.parse(payload); } catch { continue; }
if (evt.done) { chapNamesBatchProgress = `Done — ${evt.total_chapters ?? chapNamesPreview.length} chapters`; chapNamesGenerating = false; break outer; }
if (evt.error) {
chapNamesBatchWarnings = [...chapNamesBatchWarnings, `Batch ${evt.batch}/${evt.total_batches}: ${evt.error}`];
} else if (evt.chapters) {
chapNamesPreview = [...chapNamesPreview, ...evt.chapters.map((c) => ({ ...c, edited: c.new_title }))];
}
if (evt.batch != null && evt.total_batches != null) {
chapNamesBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chapNamesPreview.length}/${evt.total_chapters ?? '?'}`;
}
}
}
if (chapNamesPreview.length === 0 && chapNamesBatchWarnings.length === 0) chapNamesResult = 'error';
} catch {
chapNamesResult = 'error';
} finally {
@@ -316,7 +387,7 @@
chapNamesApplying = true;
chapNamesResult = '';
try {
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.edited?.trim() || c.new_title }));
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -890,7 +961,32 @@
<!-- Book cover generation -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
<div class="flex items-center gap-2">
<button
onclick={() => (coverPromptOpen = !coverPromptOpen)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{coverPromptOpen ? 'Hide prompt' : 'Edit prompt'}
</button>
<a href="/admin/image-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
</div>
</div>
{#if coverPromptOpen}
<textarea
bind:value={coverPrompt}
rows="3"
placeholder={buildCoverPrompt()}
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
{#if book.cover}
<label class="flex items-center gap-2 cursor-pointer select-none w-fit">
<input type="checkbox" bind:checked={coverUseAsRef} class="accent-(--color-brand)" />
<span class="text-xs text-(--color-muted)">Use current cover as reference (img2img)</span>
</label>
{/if}
{/if}
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateCover}
@@ -903,7 +999,7 @@
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{/if}
{m.book_detail_admin_generate()}
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
</button>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
@@ -932,6 +1028,12 @@
<!-- Chapter cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
<textarea
bind:value={chapterCoverPrompt}
rows="2"
placeholder="Chapter {chapterCoverN} illustration for "{data.book?.title}". Dramatic scene, vivid colors…"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
@@ -973,7 +1075,16 @@
<!-- Description generation -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
<a href="/admin/text-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
</div>
<input
type="text"
bind:value={descInstructions}
placeholder="e.g. 3-sentence blurb, avoid spoilers, dramatic tone"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateDesc}
@@ -1019,6 +1130,12 @@
<!-- Chapter names generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
<input
type="text"
bind:value={chapNamesPattern}
placeholder="Chapter {n}: {scene}"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateChapNames}
@@ -1033,18 +1150,30 @@
{/if}
{m.book_detail_admin_generate()}
</button>
{#if chapNamesBatchProgress && chapNamesGenerating}
<span class="text-xs text-(--color-muted)">{chapNamesBatchProgress}</span>
{/if}
{#if chapNamesResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if chapNamesResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
{/if}
</div>
{#if chapNamesBatchWarnings.length > 0}
{#each chapNamesBatchWarnings as w}
<p class="text-xs text-amber-400">{w}</p>
{/each}
{/if}
{#if chapNamesPreview.length > 0}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
{#each chapNamesPreview as ch}
<div class="flex gap-2 text-xs">
<div class="flex gap-2 text-xs items-center">
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
<span class="text-(--color-text) truncate">{ch.new_title}</span>
<input
type="text"
bind:value={ch.edited}
class="flex-1 min-w-0 bg-transparent border-b border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) py-0.5"
/>
</div>
{/each}
</div>
@@ -1057,7 +1186,7 @@
>
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
</button>
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
<button onclick={() => { chapNamesPreview = []; chapNamesBatchProgress = ''; chapNamesBatchWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>