feat: admin Text Gen tool — chapter names + book description via CF Workers AI
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m55s
Release / Docker / runner (push) Successful in 3m3s
Release / Docker / ui (push) Successful in 2m31s
Release / Gitea Release (push) Successful in 43s
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m55s
Release / Docker / runner (push) Successful in 3m3s
Release / Docker / ui (push) Successful in 2m31s
Release / Gitea Release (push) Successful in 43s
Adds backend handlers and SvelteKit UI for an admin text generation tool. The tool lets admins propose and apply AI-generated chapter titles and book descriptions using Cloudflare Workers AI (12 LLM models, model selector shared across both tabs).
This commit is contained in:
@@ -363,6 +363,7 @@
|
||||
"admin_nav_translation": "Translation",
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"admin_nav_translation": "Traduction",
|
||||
"admin_nav_changelog": "Modifications",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Retours",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"admin_nav_translation": "Terjemahan",
|
||||
"admin_nav_changelog": "Perubahan",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Masukan",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"admin_nav_translation": "Tradução",
|
||||
"admin_nav_changelog": "Alterações",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"admin_nav_translation": "Перевод",
|
||||
"admin_nav_changelog": "Изменения",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Отзывы",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
|
||||
@@ -334,6 +334,7 @@ export * from './admin_nav_audio.js'
|
||||
export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Text_GenInputs */
|
||||
|
||||
const en_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const id_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Text Gen" |
|
||||
*
|
||||
* @param {Admin_Nav_Text_GenInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_text_gen = /** @type {((inputs?: Admin_Nav_Text_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Text_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_text_gen(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_text_gen(inputs)
|
||||
if (locale === "id") return id_admin_nav_text_gen(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_text_gen(inputs)
|
||||
return fr_admin_nav_text_gen(inputs)
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
{ 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/image-gen', label: () => m.admin_nav_image_gen() }
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
|
||||
27
ui/src/routes/admin/text-gen/+page.server.ts
Normal file
27
ui/src/routes/admin/text-gen/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export interface TextModelInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
context_size: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as TextModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
}
|
||||
};
|
||||
508
ui/src/routes/admin/text-gen/+page.svelte
Normal file
508
ui/src/routes/admin/text-gen/+page.svelte
Normal file
@@ -0,0 +1,508 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { TextModelInfo } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const models = data.models as TextModelInfo[];
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||
type ActiveTab = 'chapters' | 'description';
|
||||
let activeTab = $state<ActiveTab>('chapters');
|
||||
let selectedModel = $state(models[0]?.id ?? '');
|
||||
|
||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||
|
||||
function fmtCtx(n: number) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
|
||||
return `${n} ctx`;
|
||||
}
|
||||
|
||||
// ── Chapter names state ───────────────────────────────────────────────────────
|
||||
let chSlug = $state('');
|
||||
let chPattern = $state('Chapter {n}: {scene}');
|
||||
let chGenerating = $state(false);
|
||||
let chError = $state('');
|
||||
|
||||
interface ProposedChapter {
|
||||
number: number;
|
||||
old_title: string;
|
||||
new_title: string;
|
||||
// editable copy
|
||||
edited: string;
|
||||
}
|
||||
let chProposals = $state<ProposedChapter[]>([]);
|
||||
let chRawResponse = $state('');
|
||||
let chUsedModel = $state('');
|
||||
let chShowRaw = $state(false);
|
||||
|
||||
let chApplying = $state(false);
|
||||
let chApplyError = $state('');
|
||||
let chApplySuccess = $state(false);
|
||||
|
||||
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
|
||||
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
|
||||
|
||||
async function generateChapterNames() {
|
||||
if (!chCanGenerate) return;
|
||||
chGenerating = true;
|
||||
chError = '';
|
||||
chProposals = [];
|
||||
chApplySuccess = false;
|
||||
chApplyError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: chSlug.trim(),
|
||||
pattern: chPattern.trim(),
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
chError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
|
||||
(p) => ({ ...p, edited: p.new_title })
|
||||
);
|
||||
chRawResponse = body.raw_response ?? '';
|
||||
chUsedModel = body.model ?? '';
|
||||
} catch {
|
||||
chError = 'Network error.';
|
||||
} finally {
|
||||
chGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChapterNames() {
|
||||
if (!chCanApply) return;
|
||||
chApplying = true;
|
||||
chApplyError = '';
|
||||
chApplySuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: chSlug.trim(),
|
||||
chapters: chProposals.map((p) => ({ number: p.number, title: p.edited.trim() || p.new_title }))
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
chApplyError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
chApplySuccess = true;
|
||||
} catch {
|
||||
chApplyError = 'Network error.';
|
||||
} finally {
|
||||
chApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description state ─────────────────────────────────────────────────────────
|
||||
let dSlug = $state('');
|
||||
let dInstructions = $state('');
|
||||
let dGenerating = $state(false);
|
||||
let dError = $state('');
|
||||
|
||||
let dOldDesc = $state('');
|
||||
let dNewDesc = $state('');
|
||||
let dUsedModel = $state('');
|
||||
|
||||
let dApplying = $state(false);
|
||||
let dApplyError = $state('');
|
||||
let dApplySuccess = $state(false);
|
||||
|
||||
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
|
||||
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
|
||||
|
||||
async function generateDescription() {
|
||||
if (!dCanGenerate) return;
|
||||
dGenerating = true;
|
||||
dError = '';
|
||||
dOldDesc = '';
|
||||
dNewDesc = '';
|
||||
dApplySuccess = false;
|
||||
dApplyError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: dSlug.trim(),
|
||||
instructions: dInstructions.trim(),
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
dError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
dOldDesc = body.old_description ?? '';
|
||||
dNewDesc = body.new_description ?? '';
|
||||
dUsedModel = body.model ?? '';
|
||||
} catch {
|
||||
dError = 'Network error.';
|
||||
} finally {
|
||||
dGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDescription() {
|
||||
if (!dCanApply) return;
|
||||
dApplying = true;
|
||||
dApplyError = '';
|
||||
dApplySuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: dSlug.trim(),
|
||||
description: dNewDesc.trim()
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
dApplyError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
dApplySuccess = true;
|
||||
} catch {
|
||||
dApplyError = 'Network error.';
|
||||
} finally {
|
||||
dApplying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Text Gen — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Text Generation</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
Generate chapter titles and book descriptions using Cloudflare Workers AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model selector (shared) -->
|
||||
<div class="space-y-1 max-w-md">
|
||||
<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)"
|
||||
>
|
||||
{#each models as m}
|
||||
<option value={m.id}>{m.label} — {m.provider} ({fmtCtx(m.context_size)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedModelInfo}
|
||||
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab toggle -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each (['chapters', 'description'] as const) as t}
|
||||
<button
|
||||
onclick={() => (activeTab = t)}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === t
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{t === 'chapters' ? 'Chapter Names' : 'Description'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Chapter names panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'chapters'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<!-- Left: form -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="ch-slug"
|
||||
type="text"
|
||||
bind:value={chSlug}
|
||||
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>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-pattern">
|
||||
Naming pattern
|
||||
</label>
|
||||
<input
|
||||
id="ch-pattern"
|
||||
type="text"
|
||||
bind:value={chPattern}
|
||||
placeholder="e.g. Chapter [n]: [brief scene description]"
|
||||
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)"
|
||||
/>
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Describe the desired chapter title format. The AI will rename every chapter to match.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={generateChapterNames}
|
||||
disabled={!chCanGenerate}
|
||||
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 chGenerating}
|
||||
<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…
|
||||
{:else}
|
||||
Generate proposals
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if chError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: proposals -->
|
||||
<div class="space-y-4">
|
||||
{#if chProposals.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
{chProposals.length} proposals
|
||||
{#if chUsedModel}
|
||||
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (chShowRaw = !chShowRaw)}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{chShowRaw ? 'Hide raw' : 'Show raw'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if chShowRaw}
|
||||
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
|
||||
{#each chProposals as proposal}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono text-(--color-muted) w-8 shrink-0">#{proposal.number}</span>
|
||||
<p class="text-xs text-(--color-muted) line-through truncate flex-1" title={proposal.old_title}>
|
||||
{proposal.old_title}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={proposal.edited}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-2.5 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button
|
||||
onclick={applyChapterNames}
|
||||
disabled={!chCanApply}
|
||||
class="flex-1 py-2 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"
|
||||
>
|
||||
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors border border-(--color-border)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if chApplyError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chApplyError}</p>
|
||||
{/if}
|
||||
{#if chApplySuccess}
|
||||
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">
|
||||
{chProposals.length} chapter titles saved successfully.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if chGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
|
||||
<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 chapter titles…</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
|
||||
<p class="text-sm text-(--color-muted)">Proposed titles will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Description panel ──────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'description'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<!-- Left: form -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="d-slug"
|
||||
type="text"
|
||||
bind:value={dSlug}
|
||||
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>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-instructions">
|
||||
Instructions <span class="normal-case font-normal text-(--color-muted)">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="d-instructions"
|
||||
bind:value={dInstructions}
|
||||
rows="3"
|
||||
placeholder="e.g. Write a 3-sentence blurb, avoid spoilers, dramatic tone."
|
||||
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>
|
||||
|
||||
<button
|
||||
onclick={generateDescription}
|
||||
disabled={!dCanGenerate}
|
||||
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 dGenerating}
|
||||
<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…
|
||||
{:else}
|
||||
Generate description
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if dError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: result -->
|
||||
<div class="space-y-4">
|
||||
{#if dNewDesc || dOldDesc}
|
||||
<div class="space-y-4">
|
||||
{#if dOldDesc}
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Current description</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3">
|
||||
<p class="text-sm text-(--color-muted) whitespace-pre-wrap">{dOldDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dNewDesc}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Proposed description
|
||||
{#if dUsedModel}
|
||||
<span class="normal-case font-normal">· {dUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={dNewDesc}
|
||||
rows="6"
|
||||
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) resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={applyDescription}
|
||||
disabled={!dCanApply}
|
||||
class="flex-1 py-2 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"
|
||||
>
|
||||
{dApplying ? 'Saving…' : dApplySuccess ? 'Saved ✓' : 'Apply description'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { dNewDesc = ''; dOldDesc = ''; dApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors border border-(--color-border)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if dApplyError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dApplyError}</p>
|
||||
{/if}
|
||||
{#if dApplySuccess}
|
||||
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Description saved successfully.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if dGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
|
||||
<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 description…</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
|
||||
<p class="text-sm text-(--color-muted)">Proposed description will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
33
ui/src/routes/api/admin/text-gen/chapter-names/+server.ts
Normal file
33
ui/src/routes/api/admin/text-gen/chapter-names/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/chapter-names
|
||||
*
|
||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
||||
* Returns AI-proposed chapter titles; does NOT persist anything.
|
||||
*/
|
||||
|
||||
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 body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/chapter-names', '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 });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/chapter-names/apply
|
||||
*
|
||||
* Admin-only proxy to persist confirmed chapter titles to PocketBase.
|
||||
*/
|
||||
|
||||
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 body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/chapter-names/apply', '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 });
|
||||
};
|
||||
33
ui/src/routes/api/admin/text-gen/description/+server.ts
Normal file
33
ui/src/routes/api/admin/text-gen/description/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/description
|
||||
*
|
||||
* Admin-only proxy to the Go backend's book description generation endpoint.
|
||||
* Returns an AI-proposed description; does NOT persist anything.
|
||||
*/
|
||||
|
||||
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 body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/description', '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 });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/description/apply
|
||||
*
|
||||
* Admin-only proxy to persist the confirmed book description to PocketBase.
|
||||
*/
|
||||
|
||||
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 body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/description/apply', '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 });
|
||||
};
|
||||
17
ui/src/routes/api/admin/text-gen/models/+server.ts
Normal file
17
ui/src/routes/api/admin/text-gen/models/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/models');
|
||||
} catch {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
Reference in New Issue
Block a user