- image-gen, text-gen: books list streamed (listBooks is expensive on cold cache) - ai-jobs: jobs list streamed; add 30s cache to listAIJobs (was uncached listAll) - changelog: Gitea releases streamed on cold cache; cached path stays synchronous - admin/+layout.svelte: remove duplicate audio/translation/image-gen nav links
775 lines
30 KiB
Svelte
775 lines
30 KiB
Svelte
<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';
|
||
|
||
let { data }: { data: PageData } = $props();
|
||
|
||
// ── Form state ───────────────────────────────────────────────────────────────
|
||
type ImageType = 'cover' | 'chapter';
|
||
|
||
const CONFIG_KEY = 'admin_image_gen_config_v1';
|
||
|
||
interface SavedConfig {
|
||
selectedModel: string;
|
||
numSteps: number;
|
||
guidance: number;
|
||
strength: number;
|
||
width: number;
|
||
height: number;
|
||
showAdvanced: boolean;
|
||
}
|
||
|
||
function loadConfig(): Partial<SavedConfig> {
|
||
if (!browser) return {};
|
||
try {
|
||
const raw = localStorage.getItem(CONFIG_KEY);
|
||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||
} catch { return {}; }
|
||
}
|
||
|
||
function saveConfig() {
|
||
if (!browser) return;
|
||
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
|
||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||
}
|
||
|
||
const saved = loadConfig();
|
||
|
||
let imageType = $state<ImageType>('cover');
|
||
let slug = $state('');
|
||
let chapter = $state<number>(1);
|
||
let selectedModel = $state(saved.selectedModel ?? '');
|
||
let prompt = $state('');
|
||
let referenceFile = $state<File | null>(null);
|
||
let referencePreviewUrl = $state('');
|
||
let useCoverAsRef = $state(false);
|
||
|
||
// Advanced
|
||
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||
let numSteps = $state(saved.numSteps ?? 20);
|
||
let guidance = $state(saved.guidance ?? 7.5);
|
||
let strength = $state(saved.strength ?? 0.75);
|
||
let width = $state(saved.width ?? 1024);
|
||
let height = $state(saved.height ?? 1024);
|
||
|
||
// Persist config on change
|
||
$effect(() => {
|
||
void selectedModel; void numSteps; void guidance; void strength;
|
||
void width; void height; void showAdvanced;
|
||
saveConfig();
|
||
});
|
||
|
||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||
// Books arrive as a streamed promise — start empty and populate on resolve.
|
||
let books = $state<BookSummary[]>([]);
|
||
$effect(() => {
|
||
data.books.then((resolved) => { books = resolved; });
|
||
});
|
||
let slugInput = $state('');
|
||
let slugFocused = $state(false);
|
||
let selectedBook = $state<BookSummary | null>(null);
|
||
|
||
let bookSuggestions = $derived(
|
||
slugInput.trim().length === 0
|
||
? []
|
||
: books
|
||
.filter((b) =>
|
||
b.slug.includes(slugInput.toLowerCase()) ||
|
||
b.title.toLowerCase().includes(slugInput.toLowerCase())
|
||
)
|
||
.slice(0, 8)
|
||
);
|
||
|
||
function selectBook(b: BookSummary) {
|
||
selectedBook = b;
|
||
slug = b.slug;
|
||
slugInput = b.slug;
|
||
slugFocused = false;
|
||
// Reset cover-as-ref if no cover
|
||
if (!b.cover) useCoverAsRef = false;
|
||
}
|
||
|
||
function onSlugInput() {
|
||
slug = slugInput;
|
||
// If user edits away from selected book slug, deselect
|
||
if (selectedBook && slugInput !== selectedBook.slug) {
|
||
selectedBook = null;
|
||
useCoverAsRef = false;
|
||
}
|
||
}
|
||
|
||
// When useCoverAsRef toggled on, load the book cover as reference
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (!useCoverAsRef || !selectedBook?.cover) {
|
||
if (useCoverAsRef) useCoverAsRef = false;
|
||
return;
|
||
}
|
||
// Fetch the cover image and set as referenceFile
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(selectedBook!.cover);
|
||
const blob = await res.blob();
|
||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
|
||
handleReferenceFile(file);
|
||
} catch {
|
||
useCoverAsRef = false;
|
||
}
|
||
})();
|
||
});
|
||
|
||
// ── Generation state ─────────────────────────────────────────────────────────
|
||
let generating = $state(false);
|
||
let genError = $state('');
|
||
|
||
// ── 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;
|
||
}
|
||
}
|
||
|
||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||
// svelte-ignore state_referenced_locally
|
||
const models: ImageModelInfo[] = data.models ?? [];
|
||
|
||
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||
let otherModels = $derived(
|
||
models.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;
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
void imageType;
|
||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||
if (preferred.length > 0) {
|
||
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 "${slugInput || '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 "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||
);
|
||
|
||
function applyTemplate() {
|
||
prompt = promptTemplate;
|
||
}
|
||
|
||
function injectDescription() {
|
||
const desc = selectedBook?.summary?.trim();
|
||
if (!desc) return;
|
||
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
|
||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||
}
|
||
|
||
// ── Auto-prompt ──────────────────────────────────────────────────────────────
|
||
let autoPrompting = $state(false);
|
||
let autoPromptError = $state('');
|
||
|
||
async function autoGeneratePrompt() {
|
||
if (!slug.trim() || autoPrompting) return;
|
||
autoPrompting = true;
|
||
autoPromptError = '';
|
||
try {
|
||
const res = await fetch('/api/admin/image-gen/auto-prompt', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ slug: slug.trim(), type: imageType, chapter: imageType === 'chapter' ? chapter : 0 })
|
||
});
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
autoPromptError = body.error ?? `Error ${res.status}`;
|
||
return;
|
||
}
|
||
prompt = body.prompt ?? '';
|
||
} catch {
|
||
autoPromptError = 'Network error.';
|
||
} finally {
|
||
autoPrompting = false;
|
||
}
|
||
}
|
||
|
||
// ── Style presets ────────────────────────────────────────────────────────────
|
||
const PRESETS_KEY = 'admin_image_gen_presets_v1';
|
||
|
||
interface StylePreset {
|
||
name: string;
|
||
prompt: string;
|
||
}
|
||
|
||
function loadPresets(): StylePreset[] {
|
||
if (!browser) return [];
|
||
try {
|
||
const raw = localStorage.getItem(PRESETS_KEY);
|
||
return raw ? (JSON.parse(raw) as StylePreset[]) : [];
|
||
} catch { return []; }
|
||
}
|
||
|
||
function savePresets(p: StylePreset[]) {
|
||
if (!browser) return;
|
||
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
|
||
}
|
||
|
||
let presets = $state<StylePreset[]>(loadPresets());
|
||
let newPresetName = $state('');
|
||
let showPresets = $state(false);
|
||
|
||
function saveCurrentAsPreset() {
|
||
const name = newPresetName.trim();
|
||
if (!name || !prompt.trim()) return;
|
||
const existing = presets.findIndex((p) => p.name === name);
|
||
const updated = [...presets];
|
||
if (existing >= 0) updated[existing] = { name, prompt };
|
||
else updated.push({ name, prompt });
|
||
presets = updated;
|
||
savePresets(updated);
|
||
newPresetName = '';
|
||
}
|
||
|
||
function applyPreset(p: StylePreset) {
|
||
prompt = p.prompt;
|
||
showPresets = false;
|
||
}
|
||
|
||
function deletePreset(name: string) {
|
||
const updated = presets.filter((p) => p.name !== name);
|
||
presets = updated;
|
||
savePresets(updated);
|
||
}
|
||
|
||
// ── 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);
|
||
useCoverAsRef = false;
|
||
}
|
||
|
||
function onDrop(e: DragEvent) {
|
||
e.preventDefault();
|
||
dragOver = false;
|
||
const file = e.dataTransfer?.files[0];
|
||
if (file && file.type.startsWith('image/')) {
|
||
handleReferenceFile(file);
|
||
useCoverAsRef = false;
|
||
}
|
||
}
|
||
|
||
function clearReference() {
|
||
handleReferenceFile(null);
|
||
useCoverAsRef = false;
|
||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||
if (input) input.value = '';
|
||
}
|
||
|
||
function fmtBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
// ── 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.`
|
||
: ''
|
||
);
|
||
</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>
|
||
<!-- Autocomplete wrapper -->
|
||
<div class="relative">
|
||
<input
|
||
id="slug-input"
|
||
type="text"
|
||
bind:value={slugInput}
|
||
oninput={onSlugInput}
|
||
onfocus={() => (slugFocused = true)}
|
||
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
|
||
placeholder="e.g. shadow-slave"
|
||
autocomplete="off"
|
||
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)"
|
||
/>
|
||
{#if slugFocused && bookSuggestions.length > 0}
|
||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||
{#each bookSuggestions as b}
|
||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||
<li
|
||
role="option"
|
||
aria-selected={selectedBook?.slug === b.slug}
|
||
onmousedown={() => selectBook(b)}
|
||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||
>
|
||
{#if b.cover}
|
||
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
|
||
{:else}
|
||
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
|
||
{/if}
|
||
<div class="min-w-0">
|
||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
<!-- Book info pill when a book is selected -->
|
||
{#if selectedBook}
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<span class="text-xs text-(--color-success) flex items-center gap-1">
|
||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||
</svg>
|
||
{selectedBook.title}
|
||
</span>
|
||
{#if selectedBook.summary}
|
||
<button
|
||
onclick={injectDescription}
|
||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||
title="Append book description to prompt"
|
||
>
|
||
+ inject description
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</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-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||
Prompt
|
||
</label>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
{#if slug.trim()}
|
||
<button onclick={autoGeneratePrompt} disabled={autoPrompting}
|
||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors disabled:opacity-50">
|
||
{autoPrompting ? 'Generating…' : 'Auto-prompt'}
|
||
</button>
|
||
{/if}
|
||
{#if selectedBook?.summary}
|
||
<button onclick={injectDescription} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||
Inject description
|
||
</button>
|
||
{/if}
|
||
<button onclick={applyTemplate} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||
Use template
|
||
</button>
|
||
{#if presets.length > 0}
|
||
<button onclick={() => (showPresets = !showPresets)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||
Presets ({presets.length})
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
{#if showPresets && presets.length > 0}
|
||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
|
||
{#each presets as p}
|
||
<div class="flex items-center gap-2 group">
|
||
<button onclick={() => applyPreset(p)} class="flex-1 text-left text-sm text-(--color-text) hover:text-(--color-brand) transition-colors truncate" title={p.prompt}>{p.name}</button>
|
||
<button onclick={() => deletePreset(p.name)} class="text-xs text-(--color-muted) hover:text-(--color-danger) transition-colors opacity-0 group-hover:opacity-100">delete</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<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>
|
||
{#if autoPromptError}
|
||
<p class="text-xs text-(--color-danger)">{autoPromptError}</p>
|
||
{/if}
|
||
|
||
<div class="flex gap-2">
|
||
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
|
||
class="flex-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-(--color-text) text-xs placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||
<button onclick={saveCurrentAsPreset} disabled={!newPresetName.trim() || !prompt.trim()}
|
||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
|
||
Save preset
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reference image drop zone -->
|
||
<div class="space-y-1">
|
||
<div class="flex items-center justify-between">
|
||
<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 selectedBook?.cover && selectedModelInfo?.supports_ref}
|
||
<div class="flex items-center gap-1.5 cursor-pointer select-none">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={useCoverAsRef}
|
||
aria-label="Use book cover as reference"
|
||
onclick={() => (useCoverAsRef = !useCoverAsRef)}
|
||
class="w-8 h-4 rounded-full transition-colors relative focus:outline-none focus:ring-1 focus:ring-(--color-brand) {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
||
>
|
||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
|
||
</button>
|
||
<span class="text-xs text-(--color-muted)">Use book cover</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{#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>
|
||
{#if useCoverAsRef}
|
||
<p class="text-xs text-(--color-brand)">Current book cover</p>
|
||
{/if}
|
||
</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">
|
||
<!-- Cloudflare AI timeout warning -->
|
||
{#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')}
|
||
<p class="text-xs text-amber-400/80 bg-amber-400/10 rounded px-2.5 py-1.5">
|
||
Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits.
|
||
</p>
|
||
{/if}
|
||
|
||
<!-- num_steps -->
|
||
<div class="space-y-1">
|
||
<div class="flex justify-between">
|
||
<label for="img-steps" class="text-xs text-(--color-muted)">Steps</label>
|
||
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
|
||
</div>
|
||
<input id="img-steps" 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 for="img-guidance" class="text-xs text-(--color-muted)">Guidance</label>
|
||
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
|
||
</div>
|
||
<input id="img-guidance" 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 for="img-strength" class="text-xs text-(--color-muted)">Strength</label>
|
||
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
|
||
</div>
|
||
<input id="img-strength" 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}
|
||
<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>
|
||
Queuing…
|
||
{:else}
|
||
Generate (async)
|
||
{/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: Info panel ──────────────────────────────────────────────────── -->
|
||
<div class="space-y-4">
|
||
<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>
|
||
|
||
<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>
|