feat: async image-gen and description jobs with review panels
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m44s
Release / Docker / runner (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m44s
Release / Docker / runner (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Add POST /api/admin/image-gen/async: fire-and-forget image generation that stores the result (base64) in an ai_job payload and returns 202 immediately — no more 60-120s blocking on FLUX models - Add POST /api/admin/text-gen/description/async: same pattern for book description generation - Register both new routes in server.go - Rewrite image-gen admin page to use the async path (submit → redirect to AI Jobs for monitoring) - Extend ai-jobs page with Review panels for image-gen jobs (show image, Save as cover / Download / Discard) and description jobs (diff old vs new, editable textarea, Apply / Discard)
This commit is contained in:
@@ -85,7 +85,8 @@
|
||||
new_title: string;
|
||||
}
|
||||
|
||||
interface ReviewState {
|
||||
interface ChapterNamesReview {
|
||||
kind: 'chapter-names';
|
||||
jobId: string;
|
||||
slug: string;
|
||||
pattern: string;
|
||||
@@ -97,39 +98,160 @@
|
||||
applyDone: boolean;
|
||||
}
|
||||
|
||||
// ── Review (image-gen jobs) ───────────────────────────────────────────────────
|
||||
|
||||
interface ImageGenReview {
|
||||
kind: 'image-gen';
|
||||
jobId: string;
|
||||
slug: string;
|
||||
imageType: string;
|
||||
prompt: string;
|
||||
imageSrc: string;
|
||||
contentType: string;
|
||||
bytes: number;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
saving: boolean;
|
||||
saveError: string;
|
||||
savedUrl: string;
|
||||
}
|
||||
|
||||
// ── Review (description jobs) ─────────────────────────────────────────────────
|
||||
|
||||
interface DescriptionReview {
|
||||
kind: 'description';
|
||||
jobId: string;
|
||||
slug: string;
|
||||
instructions: string;
|
||||
oldDescription: string;
|
||||
newDescription: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
applying: boolean;
|
||||
applyError: string;
|
||||
applyDone: boolean;
|
||||
}
|
||||
|
||||
type ReviewState = ChapterNamesReview | ImageGenReview | DescriptionReview;
|
||||
|
||||
let review = $state<ReviewState | null>(null);
|
||||
|
||||
// ── Open review ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function openReview(job: AIJob) {
|
||||
review = {
|
||||
jobId: job.id,
|
||||
slug: job.slug,
|
||||
pattern: '',
|
||||
titles: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
applying: false,
|
||||
applyError: '',
|
||||
applyDone: false
|
||||
};
|
||||
if (job.kind === 'chapter-names') {
|
||||
const r: ChapterNamesReview = {
|
||||
kind: 'chapter-names',
|
||||
jobId: job.id,
|
||||
slug: job.slug,
|
||||
pattern: '',
|
||||
titles: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
applying: false,
|
||||
applyError: '',
|
||||
applyDone: false
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try {
|
||||
payload = JSON.parse(data.payload ?? '{}');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
review.pattern = payload.pattern ?? '';
|
||||
review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
||||
review.loading = false;
|
||||
} catch (e) {
|
||||
review.loading = false;
|
||||
review.error = String(e);
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.pattern = payload.pattern ?? '';
|
||||
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
} else if (job.kind === 'image-gen') {
|
||||
const r: ImageGenReview = {
|
||||
kind: 'image-gen',
|
||||
jobId: job.id,
|
||||
slug: job.slug,
|
||||
imageType: '',
|
||||
prompt: '',
|
||||
imageSrc: '',
|
||||
contentType: 'image/png',
|
||||
bytes: 0,
|
||||
loading: true,
|
||||
error: '',
|
||||
saving: false,
|
||||
saveError: '',
|
||||
savedUrl: ''
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
if (!payload.image_b64) {
|
||||
r.error = 'No image in job payload.';
|
||||
r.loading = false;
|
||||
return;
|
||||
}
|
||||
r.imageType = payload.type ?? 'cover';
|
||||
r.prompt = payload.prompt ?? '';
|
||||
r.contentType = payload.content_type ?? 'image/png';
|
||||
r.bytes = payload.bytes ?? 0;
|
||||
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
} else if (job.kind === 'description') {
|
||||
const r: DescriptionReview = {
|
||||
kind: 'description',
|
||||
jobId: job.id,
|
||||
slug: job.slug,
|
||||
instructions: '',
|
||||
oldDescription: '',
|
||||
newDescription: '',
|
||||
loading: true,
|
||||
error: '',
|
||||
applying: false,
|
||||
applyError: '',
|
||||
applyDone: false
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
instructions?: string;
|
||||
old_description?: string;
|
||||
new_description?: string;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.instructions = payload.instructions ?? '';
|
||||
r.oldDescription = payload.old_description ?? '';
|
||||
r.newDescription = payload.new_description ?? '';
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +259,10 @@
|
||||
review = null;
|
||||
}
|
||||
|
||||
async function applyReview() {
|
||||
if (!review || review.applying) return;
|
||||
// ── Apply chapter names ───────────────────────────────────────────────────────
|
||||
|
||||
async function applyChapterNames() {
|
||||
if (review?.kind !== 'chapter-names' || review.applying) return;
|
||||
review.applying = true;
|
||||
review.applyError = '';
|
||||
review.applyDone = false;
|
||||
@@ -163,16 +287,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
// ── Save image as cover ───────────────────────────────────────────────────────
|
||||
|
||||
async function saveImageAsCover() {
|
||||
if (review?.kind !== 'image-gen' || review.saving) return;
|
||||
review.saving = true;
|
||||
review.saveError = '';
|
||||
|
||||
const b64 = review.imageSrc.split(',')[1];
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug: review.slug, image_b64: b64 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
review.saveError = body.error ?? `Error ${res.status}`;
|
||||
} else {
|
||||
review.savedUrl = body.cover_url ?? `/api/cover/novelfire.net/${review.slug}`;
|
||||
}
|
||||
} catch {
|
||||
review.saveError = 'Network error.';
|
||||
} finally {
|
||||
review.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (review?.kind !== 'image-gen') return;
|
||||
const a = document.createElement('a');
|
||||
a.href = review.imageSrc;
|
||||
const ext = review.contentType === 'image/jpeg' ? 'jpg' : 'png';
|
||||
a.download = `${review.slug}-${review.imageType}.${ext}`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── Apply description ─────────────────────────────────────────────────────────
|
||||
|
||||
async function applyDescription() {
|
||||
if (review?.kind !== 'description' || review.applying) return;
|
||||
review.applying = true;
|
||||
review.applyError = '';
|
||||
review.applyDone = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug: review.slug, description: review.newDescription })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
review.applyError = body.error ?? `Error ${res.status}`;
|
||||
} else {
|
||||
review.applyDone = true;
|
||||
}
|
||||
} catch {
|
||||
review.applyError = 'Network error.';
|
||||
} finally {
|
||||
review.applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function statusBg(status: string) {
|
||||
if (status === 'done') return 'bg-green-400/10 text-green-400';
|
||||
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
|
||||
@@ -185,6 +363,8 @@
|
||||
function kindLabel(kind: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'chapter-names': 'Chapter Names',
|
||||
'image-gen': 'Image Gen',
|
||||
'description': 'Description',
|
||||
'batch-covers': 'Batch Covers',
|
||||
'chapter-covers': 'Chapter Covers',
|
||||
'refresh-metadata': 'Refresh Metadata'
|
||||
@@ -216,6 +396,14 @@
|
||||
if (!job.items_total) return null;
|
||||
return Math.round((job.items_done / job.items_total) * 100);
|
||||
}
|
||||
|
||||
function fmtBytes(b: number) {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
@@ -390,7 +578,7 @@
|
||||
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.kind === 'chapter-names' && job.status === 'done'}
|
||||
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
|
||||
<button
|
||||
onclick={() => openReview(job)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
|
||||
@@ -419,7 +607,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Review & Apply panel ──────────────────────────────────────────────────── -->
|
||||
<!-- ── Review panel (shared backdrop + modal shell) ─────────────────────────── -->
|
||||
{#if review}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
@@ -428,102 +616,241 @@
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(900px,95vw)] sm:max-h-[85vh] sm:rounded-xl sm:border sm:shadow-2xl">
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
<span class="font-mono">{review.slug}</span>
|
||||
{#if review.pattern}
|
||||
· pattern: <span class="font-mono">{review.pattern}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={closeReview}
|
||||
class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(960px,95vw)] sm:max-h-[88vh] sm:rounded-xl sm:border sm:shadow-2xl">
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if review.loading}
|
||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">
|
||||
Loading results…
|
||||
<!-- ── Chapter names review ─────────────────────────────────────────────── -->
|
||||
{#if review.kind === 'chapter-names'}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
<span class="font-mono">{review.slug}</span>
|
||||
{#if review.pattern}
|
||||
· pattern: <span class="font-mono">{review.pattern}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else if review.error}
|
||||
<div class="px-5 py-8 text-center">
|
||||
<p class="text-(--color-danger) text-sm">{review.error}</p>
|
||||
</div>
|
||||
{:else if review.titles.length === 0}
|
||||
<div class="px-5 py-8 text-center">
|
||||
<p class="text-(--color-muted) text-sm">No results found in this job's payload.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)">
|
||||
{#each review.titles as title (title.number)}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
|
||||
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title.new_title}
|
||||
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</td>
|
||||
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if review.loading}
|
||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading results…</div>
|
||||
{:else if review.error}
|
||||
<div class="px-5 py-8 text-center"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||
{:else if review.titles.length === 0}
|
||||
<div class="px-5 py-8 text-center"><p class="text-(--color-muted) text-sm">No results found in this job's payload.</p></div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.titles.length > 0}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||
<div class="text-xs text-(--color-muted)">
|
||||
{review.titles.length} chapters
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if review.applyError}
|
||||
<p class="text-xs text-(--color-danger)">{review.applyError}</p>
|
||||
{/if}
|
||||
{#if review.applyDone}
|
||||
<p class="text-xs text-green-400">Applied successfully.</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={closeReview}
|
||||
class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onclick={applyReview}
|
||||
disabled={review.applying || review.applyDone}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
|
||||
</button>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)">
|
||||
{#each review.titles as title (title.number)}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
|
||||
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title.new_title}
|
||||
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.titles.length > 0}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||
<div class="text-xs text-(--color-muted)">{review.titles.length} chapters</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if review.applyError}<p class="text-xs text-(--color-danger)">{review.applyError}</p>{/if}
|
||||
{#if review.applyDone}<p class="text-xs text-green-400">Applied successfully.</p>{/if}
|
||||
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Close</button>
|
||||
<button
|
||||
onclick={applyChapterNames}
|
||||
disabled={review.applying || review.applyDone}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Image-gen review ─────────────────────────────────────────────────── -->
|
||||
{:else if review.kind === 'image-gen'}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Review Generated Image</h2>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
<span class="font-mono">{review.slug}</span>
|
||||
{#if review.imageType} · {review.imageType}{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
{#if review.loading}
|
||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading image…</div>
|
||||
{:else if review.error}
|
||||
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||
{:else if review.imageSrc}
|
||||
<div class="flex flex-col sm:flex-row gap-5 items-start">
|
||||
<!-- Image -->
|
||||
<div class="flex-shrink-0 w-full sm:w-64">
|
||||
<img
|
||||
src={review.imageSrc}
|
||||
alt="Generated"
|
||||
class="w-full rounded-lg border border-(--color-border) object-contain max-h-80 bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<!-- Meta -->
|
||||
<div class="flex-1 space-y-3 text-sm">
|
||||
{#if review.prompt}
|
||||
<div>
|
||||
<p class="text-xs text-(--color-muted) mb-1">Prompt</p>
|
||||
<p class="text-(--color-text) leading-relaxed">{review.prompt}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if review.bytes > 0}
|
||||
<div>
|
||||
<p class="text-xs text-(--color-muted)">Size</p>
|
||||
<p class="text-(--color-text)">{fmtBytes(review.bytes)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if review.savedUrl}
|
||||
<p class="text-xs text-green-400">
|
||||
Saved as cover →
|
||||
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{#if review.saveError}
|
||||
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.imageSrc}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
|
||||
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={downloadImage}
|
||||
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
{#if review.imageType === 'cover' && !review.savedUrl}
|
||||
<button
|
||||
onclick={saveImageAsCover}
|
||||
disabled={review.saving}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.saving ? 'Saving…' : 'Save as cover'}
|
||||
</button>
|
||||
{:else if review.savedUrl}
|
||||
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Description review ──────────────────────────────────────────────── -->
|
||||
{:else if review.kind === 'description'}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Review Description</h2>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
<span class="font-mono">{review.slug}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
{#if review.loading}
|
||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading…</div>
|
||||
{:else if review.error}
|
||||
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||
{:else}
|
||||
<!-- Old description -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Current description</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed bg-(--color-surface-2) rounded-lg px-4 py-3 border border-(--color-border)">
|
||||
{review.oldDescription || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- New description (editable) -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold text-(--color-text) uppercase tracking-wide">Proposed description <span class="normal-case font-normal text-(--color-muted)">(editable)</span></p>
|
||||
<textarea
|
||||
bind:value={review.newDescription}
|
||||
rows="6"
|
||||
class="w-full px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/50 text-sm text-(--color-text) leading-relaxed resize-y focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if review.instructions}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
<span class="font-medium text-(--color-text)">Instructions:</span> {review.instructions}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if review.applyError}<p class="text-xs text-(--color-danger)">{review.applyError}</p>{/if}
|
||||
{#if review.applyDone}<p class="text-xs text-green-400">Applied successfully.</p>{/if}
|
||||
<button
|
||||
onclick={applyDescription}
|
||||
disabled={review.applying || review.applyDone || !review.newDescription.trim()}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
@@ -120,30 +121,58 @@
|
||||
// ── Generation state ─────────────────────────────────────────────────────────
|
||||
let generating = $state(false);
|
||||
let genError = $state('');
|
||||
let elapsedMs = $state(0);
|
||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Result state ─────────────────────────────────────────────────────────────
|
||||
interface GenResult {
|
||||
imageSrc: string;
|
||||
model: string;
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
saved: boolean;
|
||||
coverUrl: string;
|
||||
elapsedMs: number;
|
||||
slug: string;
|
||||
imageType: ImageType;
|
||||
chapter: number;
|
||||
// ── Generate (async: fire-and-forget → redirect to ai-jobs) ─────────────────
|
||||
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
|
||||
|
||||
async function generate() {
|
||||
if (!canGenerate) return;
|
||||
generating = true;
|
||||
genError = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
type: imageType,
|
||||
slug: slug.trim(),
|
||||
chapter: imageType === 'chapter' ? chapter : 0,
|
||||
num_steps: numSteps,
|
||||
guidance,
|
||||
strength,
|
||||
width,
|
||||
height
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
if (referenceFile && selectedModelInfo?.supports_ref) {
|
||||
const fd = new FormData();
|
||||
fd.append('json', JSON.stringify(payload));
|
||||
fd.append('reference', referenceFile);
|
||||
res = await fetch('/api/admin/image-gen/async', { method: 'POST', body: fd });
|
||||
} else {
|
||||
res = await fetch('/api/admin/image-gen/async', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to ai-jobs so the admin can monitor progress and review.
|
||||
await goto('/admin/ai-jobs');
|
||||
} catch {
|
||||
genError = 'Network error.';
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
let result = $state<GenResult | null>(null);
|
||||
let history = $state<GenResult[]>([]);
|
||||
|
||||
let saving = $state(false);
|
||||
let saveError = $state('');
|
||||
let saveSuccess = $state(false);
|
||||
|
||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: ImageModelInfo[] = data.models ?? [];
|
||||
@@ -307,147 +336,6 @@
|
||||
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
|
||||
: ''
|
||||
);
|
||||
|
||||
// ── Generate ────────────────────────────────────────────────────────────────
|
||||
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
|
||||
|
||||
async function generate() {
|
||||
if (!canGenerate) return;
|
||||
generating = true;
|
||||
genError = '';
|
||||
result = null;
|
||||
elapsedMs = 0;
|
||||
saveSuccess = false;
|
||||
saveError = '';
|
||||
|
||||
const startTs = Date.now();
|
||||
elapsedInterval = setInterval(() => {
|
||||
elapsedMs = Date.now() - startTs;
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
type: imageType,
|
||||
slug: slug.trim(),
|
||||
chapter: imageType === 'chapter' ? chapter : 0,
|
||||
num_steps: numSteps,
|
||||
guidance,
|
||||
strength,
|
||||
width,
|
||||
height
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
if (referenceFile && selectedModelInfo?.supports_ref) {
|
||||
const fd = new FormData();
|
||||
fd.append('json', JSON.stringify(payload));
|
||||
fd.append('reference', referenceFile);
|
||||
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
|
||||
} else {
|
||||
res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 502 || res.status === 504) {
|
||||
genError =
|
||||
body.error ??
|
||||
`Generation timed out (${res.status}). FLUX models can take 60–120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
|
||||
} else {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - startTs;
|
||||
const newResult: GenResult = {
|
||||
imageSrc: `data:${body.content_type};base64,${body.image_b64}`,
|
||||
model: body.model,
|
||||
bytes: body.bytes,
|
||||
contentType: body.content_type,
|
||||
saved: body.saved ?? false,
|
||||
coverUrl: body.cover_url ?? '',
|
||||
elapsedMs: totalMs,
|
||||
slug: slug.trim(),
|
||||
imageType,
|
||||
chapter
|
||||
};
|
||||
|
||||
result = newResult;
|
||||
history = [newResult, ...history].slice(0, 5);
|
||||
} catch {
|
||||
genError = 'Network error.';
|
||||
} finally {
|
||||
generating = false;
|
||||
if (elapsedInterval) {
|
||||
clearInterval(elapsedInterval);
|
||||
elapsedInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save as cover ────────────────────────────────────────────────────────────
|
||||
async function saveAsCover() {
|
||||
if (!result || saving) return;
|
||||
saving = true;
|
||||
saveError = '';
|
||||
saveSuccess = false;
|
||||
|
||||
try {
|
||||
const b64 = result.imageSrc.split(',')[1];
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: result.slug, image_b64: b64 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
saveError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
if (body.saved) {
|
||||
saveSuccess = true;
|
||||
result = { ...result, saved: true, coverUrl: body.cover_url ?? result.coverUrl };
|
||||
} else {
|
||||
saveError = 'Backend did not save the cover.';
|
||||
}
|
||||
} catch {
|
||||
saveError = 'Network error.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────────
|
||||
function download() {
|
||||
if (!result) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = result.imageSrc;
|
||||
const ext = result.contentType === 'image/jpeg' ? 'jpg' : 'png';
|
||||
a.download =
|
||||
result.imageType === 'cover'
|
||||
? `${result.slug}-cover.${ext}`
|
||||
: `${result.slug}-ch${result.chapter}.${ext}`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── Formatting helpers ───────────────────────────────────────────────────────
|
||||
function fmtElapsed(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function fmtBytes(b: number) {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -830,9 +718,9 @@
|
||||
<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… {fmtElapsed(elapsedMs)}
|
||||
Queuing…
|
||||
{:else}
|
||||
Generate
|
||||
Generate (async)
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -841,116 +729,37 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Right: Result panel ────────────────────────────────────────────────── -->
|
||||
<!-- ── Right: Info panel ──────────────────────────────────────────────────── -->
|
||||
<div class="space-y-4">
|
||||
{#if result}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<!-- Image -->
|
||||
<img
|
||||
src={result.imageSrc}
|
||||
alt=""
|
||||
aria-label="Generated cover"
|
||||
class="w-full object-contain max-h-[36rem] bg-zinc-950"
|
||||
/>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-(--color-text)">How it works</h2>
|
||||
<ol class="space-y-2 text-sm text-(--color-muted) list-decimal list-inside">
|
||||
<li>Fill in the form and click <strong class="text-(--color-text)">Generate (async)</strong>.</li>
|
||||
<li>The job is queued in the background — no waiting on this page.</li>
|
||||
<li>You'll be taken to <strong class="text-(--color-text)">AI Jobs</strong> to monitor progress.</li>
|
||||
<li>When done, click <strong class="text-(--color-text)">Review</strong> to see the image and approve or discard it.</li>
|
||||
</ol>
|
||||
<a
|
||||
href="/admin/ai-jobs"
|
||||
class="mt-3 flex items-center gap-1.5 text-sm text-(--color-brand) hover:underline"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Go to AI Jobs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Meta bar -->
|
||||
<div class="px-4 py-3 border-t border-(--color-border) space-y-3">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<p class="text-(--color-muted)">Model</p>
|
||||
<p class="text-(--color-text) font-mono truncate" title={result.model}>
|
||||
{models.find((m) => m.id === result!.model)?.label ?? result.model}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-(--color-muted)">Size</p>
|
||||
<p class="text-(--color-text)">{fmtBytes(result.bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-(--color-muted)">Time</p>
|
||||
<p class="text-(--color-text)">{fmtElapsed(result.elapsedMs)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result.saved}
|
||||
<p class="text-xs text-green-400">
|
||||
Cover saved →
|
||||
<a href={result.coverUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="underline hover:text-green-300">{result.coverUrl}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if saveSuccess && !result.saved}
|
||||
<p class="text-xs text-green-400">Cover saved successfully.</p>
|
||||
{/if}
|
||||
{#if saveError}
|
||||
<p class="text-xs text-(--color-danger)">{saveError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onclick={download}
|
||||
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) text-xs font-medium hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
||||
{#if result.imageType === 'cover'}
|
||||
<button
|
||||
onclick={saveAsCover}
|
||||
disabled={saving || result.saved}
|
||||
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) text-xs font-semibold
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : result.saved ? 'Saved ✓' : 'Save as cover'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if generating}
|
||||
<!-- Placeholder while generating -->
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-80">
|
||||
<div class="text-center space-y-3">
|
||||
<svg class="w-8 h-8 animate-spin mx-auto 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>
|
||||
<p class="text-sm text-(--color-muted)">Generating… {fmtElapsed(elapsedMs)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty state -->
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-80">
|
||||
<p class="text-sm text-(--color-muted)">Generated image will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History thumbnails -->
|
||||
{#if history.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Session history</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each history as h, i}
|
||||
<button
|
||||
onclick={() => result = h}
|
||||
class="relative rounded-md overflow-hidden border transition-colors shrink-0
|
||||
{result === h ? 'border-(--color-brand)' : 'border-(--color-border) hover:border-(--color-brand)/50'}"
|
||||
>
|
||||
<img
|
||||
src={h.imageSrc}
|
||||
alt="History {i + 1}"
|
||||
class="w-16 h-16 object-cover"
|
||||
/>
|
||||
{#if h.saved}
|
||||
<span class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border border-(--color-surface)"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Tips</p>
|
||||
<ul class="space-y-1.5 text-xs text-(--color-muted)">
|
||||
<li>• Use <strong class="text-(--color-text)">Auto-prompt</strong> to generate a prompt from the book's description.</li>
|
||||
<li>• FLUX models produce high-quality covers but take 60–120 s — the async path prevents timeouts.</li>
|
||||
<li>• Keep steps ≤ 20 on Cloudflare Workers AI to stay within the ~100 s limit.</li>
|
||||
<li>• Reference images (img2img) only work with models that show ★ref.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user