feat: admin image generation via Cloudflare Workers AI
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 3m3s
Release / Docker / runner (push) Successful in 2m57s
Release / Gitea Release (push) Has been skipped

- Add cfai/image.go: ImageGenClient with GenerateImage, GenerateImageFromReference, AllImageModels (9 models)
- Add handlers_image.go: GET /api/admin/image-gen/models + POST /api/admin/image-gen (JSON + multipart)
- Wire ImageGen client in main.go + server.go Dependencies
- Add admin/image-gen SvelteKit page: type toggle, model selector, prompt, reference img2img, advanced options, result panel, history, save-as-cover, download
- Add SvelteKit proxy route api/admin/image-gen forwarding to Go backend
- Add admin_nav_image_gen message key to all 5 locale files
- Add Image Gen nav link to admin layout
This commit is contained in:
Admin
2026-04-04 11:46:22 +05:00
parent aaa008ac99
commit d1b7d3e36c
13 changed files with 1320 additions and 1 deletions

View File

@@ -362,6 +362,7 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",

View File

@@ -362,6 +362,7 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Traduction",
"admin_nav_changelog": "Modifications",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Retours",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",

View File

@@ -362,6 +362,7 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Terjemahan",
"admin_nav_changelog": "Perubahan",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Masukan",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",

View File

@@ -362,6 +362,7 @@
"admin_nav_audio": "Áudio",
"admin_nav_translation": "Tradução",
"admin_nav_changelog": "Alterações",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",

View File

@@ -362,6 +362,7 @@
"admin_nav_audio": "Аудио",
"admin_nav_translation": "Перевод",
"admin_nav_changelog": "Изменения",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Отзывы",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",

View File

@@ -6,7 +6,8 @@
{ href: '/admin/scrape', label: () => m.admin_nav_scrape() },
{ href: '/admin/audio', label: () => m.admin_nav_audio() },
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() }
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }
];
const externalLinks = [

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
id: string;
label: string;
provider: string;
supports_ref: boolean;
recommended_for: string[]; // "cover" | "chapter"
description: string;
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) {
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
return { models: [] as ImageModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as ImageModelInfo[] };
} catch (e) {
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
return { models: [] as ImageModelInfo[] };
}
};

View File

@@ -0,0 +1,676 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ImageModelInfo } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state('');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
// Advanced
let showAdvanced = $state(false);
let numSteps = $state(20);
let guidance = $state(7.5);
let strength = $state(0.75);
let width = $state(1024);
let height = $state(1024);
// ── 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;
}
let result = $state<GenResult | null>(null);
let history = $state<GenResult[]>([]);
let saving = $state(false);
let saveError = $state('');
let saveSuccess = $state(false);
// ── Model helpers ────────────────────────────────────────────────────────────
const models = data.models as ImageModelInfo[];
let filteredModels = $derived(
referenceFile
? models // show all; warn on ones without ref support
: models
);
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
let otherModels = $derived(
filteredModels.filter(
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
)
);
// ── Auto-select default model when type changes ──────────────────────────────
$effect(() => {
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (!selectedModel && preferred.length > 0) {
selectedModel = preferred[0].id;
}
});
// Reset model selection when type changes if current selection no longer fits
$effect(() => {
void imageType; // track
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (preferred.length > 0) {
// only auto-switch if current model isn't in preferred list for this type
const current = models.find((m) => m.id === selectedModel);
if (!current || !current.recommended_for.includes(imageType)) {
selectedModel = preferred[0].id;
}
}
});
// ── Prompt templates ────────────────────────────────────────────────────────
let promptTemplate = $derived(
imageType === 'cover'
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
);
function applyTemplate() {
prompt = promptTemplate;
}
// ── Reference image handling ─────────────────────────────────────────────────
let dragOver = $state(false);
function handleReferenceFile(file: File | null) {
referenceFile = file;
if (referencePreviewUrl) URL.revokeObjectURL(referencePreviewUrl);
referencePreviewUrl = file ? URL.createObjectURL(file) : '';
}
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
handleReferenceFile(input.files?.[0] ?? null);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
}
function clearReference() {
handleReferenceFile(null);
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
if (input) input.value = '';
}
// ── Selected model info ──────────────────────────────────────────────────────
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
let refWarning = $derived(
referenceFile && selectedModelInfo && !selectedModelInfo.supports_ref
? `${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) {
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 payload = {
prompt: prompt.trim(),
model: result.model,
type: 'cover',
slug: result.slug,
num_steps: numSteps,
guidance,
strength,
width,
height,
save_to_cover: true
};
// Re-generate with save_to_cover=true (backend saves atomically)
// Alternatively, we could add a separate save endpoint.
// For now we pass the same prompt + model to re-generate and save.
// TODO: A lighter approach would be a dedicated save endpoint that accepts
// the base64 payload. For now re-gen is acceptable given admin-only usage.
const 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) {
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>
<title>Image Gen — Admin</title>
</svelte:head>
<div class="space-y-6 max-w-6xl">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Image Generation</h1>
<p class="text-(--color-muted) text-sm mt-1">
Generate book covers and chapter images using Cloudflare Workers AI.
</p>
</div>
<!-- Layout: form + result side by side on large screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- ── Left: Form panel ──────────────────────────────────────────────────── -->
<div class="space-y-4">
<!-- Type toggle -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each (['cover', 'chapter'] as const) as t}
<button
onclick={() => (imageType = t)}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{imageType === t
? 'bg-(--color-surface-3) text-(--color-text)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{t === 'cover' ? 'Cover' : 'Chapter Image'}
</button>
{/each}
</div>
<!-- Slug + chapter -->
<div class="flex gap-3">
<div class="flex-1 min-w-0 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
Book slug
</label>
<input
id="slug-input"
type="text"
bind:value={slug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
{#if imageType === 'chapter'}
<div class="w-24 space-y-1 shrink-0">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="chapter-input">
Chapter
</label>
<input
id="chapter-input"
type="number"
bind:value={chapter}
min="1"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
{/if}
</div>
<!-- Model selector -->
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="model-select">
Model
</label>
<select
id="model-select"
bind:value={selectedModel}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
>
{#if coverModels.length > 0}
<optgroup label="Recommended for covers">
{#each coverModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if chapterModels.length > 0}
<optgroup label="Recommended for chapters">
{#each chapterModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if otherModels.length > 0}
<optgroup label="All models">
{#each otherModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
</select>
{#if selectedModelInfo}
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
{/if}
{#if refWarning}
<p class="text-xs text-amber-400">{refWarning}</p>
{/if}
</div>
<!-- Prompt -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
Prompt
</label>
<button
onclick={applyTemplate}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
Use template
</button>
</div>
<textarea
id="prompt-input"
bind:value={prompt}
rows="5"
placeholder="Describe the image to generate…"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
></textarea>
</div>
<!-- Reference image drop zone -->
<div class="space-y-1">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
{#if referenceFile && referencePreviewUrl}
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
<img
src={referencePreviewUrl}
alt="Reference"
class="w-16 h-16 object-cover rounded-md shrink-0 border border-(--color-border)"
/>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
</div>
<button
onclick={clearReference}
class="text-(--color-muted) hover:text-(--color-text) transition-colors shrink-0"
aria-label="Remove reference image"
>
<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>
{:else}
<!-- Drop zone -->
<label
class="flex flex-col items-center justify-center gap-2 p-4 border-2 border-dashed rounded-lg cursor-pointer transition-colors
{dragOver
? 'border-(--color-brand) bg-(--color-brand)/5'
: 'border-(--color-border) hover:border-(--color-brand)/50 hover:bg-(--color-surface-2)'}"
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => { dragOver = false; }}
ondrop={onDrop}
>
<svg class="w-6 h-6 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
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>
<span class="text-xs text-(--color-muted)">Drop image or <span class="text-(--color-brand)">click to browse</span></span>
<input
id="ref-file-input"
type="file"
accept="image/png,image/jpeg,image/webp"
onchange={onFileInput}
class="sr-only"
/>
</label>
{/if}
</div>
<!-- Advanced collapsible -->
<div class="border border-(--color-border) rounded-lg overflow-hidden">
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Advanced options
<svg
class="w-4 h-4 transition-transform {showAdvanced ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showAdvanced}
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Steps</label>
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
</div>
<input type="range" min="1" max="20" step="1" bind:value={numSteps}
class="w-full accent-(--color-brand)" />
</div>
<!-- guidance -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Guidance</label>
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
</div>
<input type="range" min="1" max="20" step="0.5" bind:value={guidance}
class="w-full accent-(--color-brand)" />
</div>
<!-- strength (only when reference present) -->
{#if referenceFile}
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Strength</label>
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" bind:value={strength}
class="w-full accent-(--color-brand)" />
<p class="text-xs text-(--color-muted)">0 = copy reference · 1 = ignore reference</p>
</div>
{/if}
<!-- width × height -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="width-input">Width</label>
<input id="width-input" type="number" min="256" max="2048" step="64" bind:value={width}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="height-input">Height</label>
<input id="height-input" type="number" min="256" max="2048" step="64" bind:value={height}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
</div>
</div>
{/if}
</div>
<!-- Generate button -->
<button
onclick={generate}
disabled={!canGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{#if generating}
<!-- Spinner -->
<svg class="w-4 h-4 animate-spin" 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>
Generating… {fmtElapsed(elapsedMs)}
{:else}
Generate
{/if}
</button>
{#if genError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{genError}</p>
{/if}
</div>
<!-- ── Right: Result 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="Generated image"
class="w-full object-contain max-h-[36rem] bg-zinc-950"
/>
<!-- 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 &rarr;
<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>
</div>
</div>

View File

@@ -0,0 +1,46 @@
/**
* POST /api/admin/image-gen
*
* Admin-only proxy to the Go backend's image generation endpoint.
* Transparently forwards the request body (JSON or multipart/form-data)
* and returns the JSON response containing the base64-encoded image.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const ct = request.headers.get('content-type') ?? '';
let res: Response;
try {
if (ct.includes('multipart/form-data')) {
// Forward raw body bytes; let the backend parse multipart
const body = await request.arrayBuffer();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': ct },
body
});
} else {
const body = await request.text();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
}
} catch (e) {
log.error('admin/image-gen', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};