Compare commits
1 Commits
v2.5.76
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40151f2f33 |
@@ -138,6 +138,21 @@
|
|||||||
let coverPreview = $state<string | null>(null);
|
let coverPreview = $state<string | null>(null);
|
||||||
let coverSaving = $state(false);
|
let coverSaving = $state(false);
|
||||||
let coverResult = $state<'saved' | 'error' | ''>('');
|
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() {
|
async function generateCover() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -145,12 +160,34 @@
|
|||||||
coverGenerating = true;
|
coverGenerating = true;
|
||||||
coverPreview = null;
|
coverPreview = null;
|
||||||
coverResult = '';
|
coverResult = '';
|
||||||
|
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/image-gen', {
|
let res: Response;
|
||||||
method: 'POST',
|
if (coverUseAsRef && data.book?.cover) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
|
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) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
||||||
@@ -195,6 +232,7 @@
|
|||||||
let chapterCoverGenerating = $state(false);
|
let chapterCoverGenerating = $state(false);
|
||||||
let chapterCoverPreview = $state<string | null>(null);
|
let chapterCoverPreview = $state<string | null>(null);
|
||||||
let chapterCoverResult = $state<'error' | ''>('');
|
let chapterCoverResult = $state<'error' | ''>('');
|
||||||
|
let chapterCoverPrompt = $state('');
|
||||||
|
|
||||||
async function generateChapterCover() {
|
async function generateChapterCover() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -204,11 +242,12 @@
|
|||||||
chapterCoverGenerating = true;
|
chapterCoverGenerating = true;
|
||||||
chapterCoverPreview = null;
|
chapterCoverPreview = null;
|
||||||
chapterCoverResult = '';
|
chapterCoverResult = '';
|
||||||
|
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/image-gen', {
|
const res = await fetch('/api/admin/image-gen', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -228,6 +267,7 @@
|
|||||||
let descPreview = $state('');
|
let descPreview = $state('');
|
||||||
let descApplying = $state(false);
|
let descApplying = $state(false);
|
||||||
let descResult = $state<'applied' | 'error' | ''>('');
|
let descResult = $state<'applied' | 'error' | ''>('');
|
||||||
|
let descInstructions = $state('');
|
||||||
|
|
||||||
async function generateDesc() {
|
async function generateDesc() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -239,7 +279,7 @@
|
|||||||
const res = await fetch('/api/admin/text-gen/description', {
|
const res = await fetch('/api/admin/text-gen/description', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slug })
|
body: JSON.stringify({ slug, instructions: descInstructions.trim() || undefined })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -281,9 +321,12 @@
|
|||||||
|
|
||||||
// ── Admin: chapter names generation ───────────────────────────────────────
|
// ── Admin: chapter names generation ───────────────────────────────────────
|
||||||
let chapNamesGenerating = $state(false);
|
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 chapNamesApplying = $state(false);
|
||||||
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
||||||
|
let chapNamesPattern = $state('Chapter {n}: {scene}');
|
||||||
|
let chapNamesBatchProgress = $state('');
|
||||||
|
let chapNamesBatchWarnings = $state<string[]>([]);
|
||||||
|
|
||||||
async function generateChapNames() {
|
async function generateChapNames() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -291,18 +334,46 @@
|
|||||||
chapNamesGenerating = true;
|
chapNamesGenerating = true;
|
||||||
chapNamesPreview = [];
|
chapNamesPreview = [];
|
||||||
chapNamesResult = '';
|
chapNamesResult = '';
|
||||||
|
chapNamesBatchProgress = '';
|
||||||
|
chapNamesBatchWarnings = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!res.ok) {
|
||||||
const d = await res.json();
|
|
||||||
chapNamesPreview = d.chapters ?? [];
|
|
||||||
} else {
|
|
||||||
chapNamesResult = 'error';
|
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 {
|
} catch {
|
||||||
chapNamesResult = 'error';
|
chapNamesResult = 'error';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -316,7 +387,7 @@
|
|||||||
chapNamesApplying = true;
|
chapNamesApplying = true;
|
||||||
chapNamesResult = '';
|
chapNamesResult = '';
|
||||||
try {
|
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', {
|
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -890,7 +961,32 @@
|
|||||||
|
|
||||||
<!-- Book cover generation -->
|
<!-- Book cover generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<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">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateCover}
|
onclick={generateCover}
|
||||||
@@ -903,7 +999,7 @@
|
|||||||
{:else}
|
{: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>
|
<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}
|
{/if}
|
||||||
{m.book_detail_admin_generate()}
|
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
|
||||||
</button>
|
</button>
|
||||||
{#if coverResult === 'error'}
|
{#if coverResult === 'error'}
|
||||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||||
@@ -932,6 +1028,12 @@
|
|||||||
<!-- Chapter cover generation -->
|
<!-- Chapter cover generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<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>
|
<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 items-end gap-3 flex-wrap">
|
||||||
<div class="flex flex-col gap-1">
|
<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>
|
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
|
||||||
@@ -973,7 +1075,16 @@
|
|||||||
|
|
||||||
<!-- Description generation -->
|
<!-- Description generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<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">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateDesc}
|
onclick={generateDesc}
|
||||||
@@ -1019,6 +1130,12 @@
|
|||||||
<!-- Chapter names generation -->
|
<!-- Chapter names generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<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>
|
<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">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateChapNames}
|
onclick={generateChapNames}
|
||||||
@@ -1033,18 +1150,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{m.book_detail_admin_generate()}
|
{m.book_detail_admin_generate()}
|
||||||
</button>
|
</button>
|
||||||
|
{#if chapNamesBatchProgress && chapNamesGenerating}
|
||||||
|
<span class="text-xs text-(--color-muted)">{chapNamesBatchProgress}</span>
|
||||||
|
{/if}
|
||||||
{#if chapNamesResult === 'error'}
|
{#if chapNamesResult === 'error'}
|
||||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||||
{:else if chapNamesResult === 'applied'}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if chapNamesBatchWarnings.length > 0}
|
||||||
|
{#each chapNamesBatchWarnings as w}
|
||||||
|
<p class="text-xs text-amber-400">{w}</p>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{#if chapNamesPreview.length > 0}
|
{#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)">
|
<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}
|
{#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-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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1057,7 +1186,7 @@
|
|||||||
>
|
>
|
||||||
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user