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
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:
@@ -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', {
|
||||
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<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">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
||||
<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">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user