diff --git a/backend/internal/backend/handlers_image.go b/backend/internal/backend/handlers_image.go index 2862532..99ee8f1 100644 --- a/backend/internal/backend/handlers_image.go +++ b/backend/internal/backend/handlers_image.go @@ -195,7 +195,7 @@ func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) { // Non-fatal: still return the image } else { saved = true - coverURL = fmt.Sprintf("/api/cover/local/%s", req.Slug) + coverURL = fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug) s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData)) } } @@ -213,6 +213,62 @@ func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) { }) } +// saveCoverRequest is the JSON body for POST /api/admin/image-gen/save-cover. +type saveCoverRequest struct { + // Slug is the book slug whose cover should be overwritten. + Slug string `json:"slug"` + // ImageB64 is the base64-encoded image bytes (PNG or JPEG). + ImageB64 string `json:"image_b64"` +} + +// handleAdminImageGenSaveCover handles POST /api/admin/image-gen/save-cover. +// +// Accepts a pre-generated image as base64 and stores it as the book cover in +// MinIO, replacing the existing one. Does not call Cloudflare AI at all. +func (s *Server) handleAdminImageGenSaveCover(w http.ResponseWriter, r *http.Request) { + if s.deps.CoverStore == nil { + jsonError(w, http.StatusServiceUnavailable, "cover store not configured") + return + } + + var req saveCoverRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if req.Slug == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if req.ImageB64 == "" { + jsonError(w, http.StatusBadRequest, "image_b64 is required") + return + } + + imgData, err := base64.StdEncoding.DecodeString(req.ImageB64) + if err != nil { + imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64) + if err != nil { + jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error()) + return + } + } + + contentType := sniffImageContentType(imgData) + if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil { + s.deps.Log.Error("admin: save-cover failed", "slug", req.Slug, "err", err) + jsonError(w, http.StatusInternalServerError, "save cover: "+err.Error()) + return + } + + s.deps.Log.Info("admin: cover saved via image-gen", "slug", req.Slug, "bytes", len(imgData)) + writeJSON(w, 0, map[string]any{ + "saved": true, + "cover_url": fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug), + "bytes": len(imgData), + }) +} + // sniffImageContentType returns the MIME type of the image bytes. func sniffImageContentType(data []byte) string { if len(data) >= 4 { diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index a524ffd..4fb7f4a 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -189,6 +189,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Admin image generation endpoints mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels) mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen) + mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover) // Voices list mux.HandleFunc("GET /api/voices", s.handleVoices) diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 56a6fc2..382d75f 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -333,6 +333,7 @@ export * from './admin_nav_scrape.js' 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_feedback.js' export * from './admin_nav_errors.js' export * from './admin_nav_analytics.js' diff --git a/ui/src/lib/paraglide/messages/admin_nav_image_gen.js b/ui/src/lib/paraglide/messages/admin_nav_image_gen.js new file mode 100644 index 0000000..f2fa6eb --- /dev/null +++ b/ui/src/lib/paraglide/messages/admin_nav_image_gen.js @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Admin_Nav_Image_GenInputs */ + +const en_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Image Gen`) +}; + +const ru_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Image Gen`) +}; + +const id_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Image Gen`) +}; + +const pt_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Image Gen`) +}; + +const fr_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Image Gen`) +}; + +/** +* | output | +* | --- | +* | "Image Gen" | +* +* @param {Admin_Nav_Image_GenInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ +export const admin_nav_image_gen = /** @type {((inputs?: Admin_Nav_Image_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_admin_nav_image_gen(inputs) + if (locale === "ru") return ru_admin_nav_image_gen(inputs) + if (locale === "id") return id_admin_nav_image_gen(inputs) + if (locale === "pt") return pt_admin_nav_image_gen(inputs) + return fr_admin_nav_image_gen(inputs) +}); \ No newline at end of file diff --git a/ui/src/routes/admin/image-gen/+page.svelte b/ui/src/routes/admin/image-gen/+page.svelte index b4183d9..1ab9b9d 100644 --- a/ui/src/routes/admin/image-gen/+page.svelte +++ b/ui/src/routes/admin/image-gen/+page.svelte @@ -219,28 +219,12 @@ 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', { + // Extract the raw base64 from the data URL (data:;base64,) + 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(payload) + body: JSON.stringify({ slug: result.slug, image_b64: b64 }) }); const body = await res.json().catch(() => ({})); if (!res.ok) { diff --git a/ui/src/routes/api/admin/image-gen/save-cover/+server.ts b/ui/src/routes/api/admin/image-gen/save-cover/+server.ts new file mode 100644 index 0000000..e89a0be --- /dev/null +++ b/ui/src/routes/api/admin/image-gen/save-cover/+server.ts @@ -0,0 +1,35 @@ +/** + * POST /api/admin/image-gen/save-cover + * + * Admin-only proxy: persists a pre-generated base64 image as a book cover in + * MinIO without re-calling Cloudflare AI. + * + * Body: { slug: string, image_b64: string } + */ + +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/image-gen/save-cover', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/image-gen/save-cover', '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 }); +};