From 40151f2f3322d3b9c5d164ebf6ac638695d4b0f1 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 5 Apr 2026 00:45:12 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20enhance=20admin=20panel=20on=20book=20p?= =?UTF-8?q?age=20=E2=80=94=20prompts,=20img2img,=20SSE=20chapter=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/src/routes/books/[slug]/+page.svelte | 171 +++++++++++++++++++++--- 1 file changed, 150 insertions(+), 21 deletions(-) diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 2b19f28..aeff74e 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -138,6 +138,21 @@ let coverPreview = $state(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', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug }) - }); + 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: 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(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([]); 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 @@
-

{m.book_detail_admin_book_cover()}

+
+

{m.book_detail_admin_book_cover()}

+
+ + Full editor ↗ +
+
+ {#if coverPromptOpen} + + {#if book.cover} + + {/if} + {/if}
{#if coverResult === 'error'} {m.common_error()} @@ -932,6 +1028,12 @@

{m.book_detail_admin_chapter_cover()}

+
@@ -973,7 +1075,16 @@
-

{m.book_detail_admin_description()}

+
+

{m.book_detail_admin_description()}

+ Full editor ↗ +
+
+ {#if chapNamesBatchWarnings.length > 0} + {#each chapNamesBatchWarnings as w} +

{w}

+ {/each} + {/if} {#if chapNamesPreview.length > 0}
{#each chapNamesPreview as ch} -
+
{ch.number}. - {ch.new_title} +
{/each}
@@ -1057,7 +1186,7 @@ > {chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length}) - +
{/if}