From 6f0069daca22418c2202df846a7c96dd4c266f0b Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 5 Apr 2026 10:52:38 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20catalogue=20enrichment=20=E2=80=94=20ta?= =?UTF-8?q?gline,=20genres,=20warnings,=20quality=20score,=20batch=20cover?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (handlers_catalogue.go): - POST /api/admin/text-gen/tagline — 1-sentence marketing hook - POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist - POST /api/admin/text-gen/content-warnings — mature theme detection - POST /api/admin/text-gen/quality-score — 1–5 description quality rating - POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one - POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry - POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh Frontend: - text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete - image-gen: localStorage style presets (save/apply/delete named prompt templates) - catalogue-tools: new admin page with batch cover SSE progress + cancel - admin nav: "Catalogue Tools" link added Co-Authored-By: Claude Sonnet 4.6 --- .../internal/backend/handlers_catalogue.go | 728 ++++++++++++++++++ backend/internal/backend/server.go | 10 + ui/messages/en.json | 1 + ui/messages/fr.json | 2 +- ui/messages/id.json | 2 +- ui/messages/pt.json | 2 +- ui/messages/ru.json | 2 +- ui/src/lib/paraglide/messages.js | 4 +- ui/src/lib/paraglide/messages/_index.js | 1 + .../messages/admin_nav_catalogue_tools.js | 44 ++ .../paraglide/messages/admin_nav_feedback.js | 20 +- ui/src/routes/admin/+layout.svelte | 3 +- .../admin/catalogue-tools/+page.server.ts | 24 + .../routes/admin/catalogue-tools/+page.svelte | 257 +++++++ ui/src/routes/admin/image-gen/+page.svelte | 92 ++- ui/src/routes/admin/text-gen/+page.svelte | 467 ++++++++++- .../admin/catalogue/batch-covers/+server.ts | 43 ++ .../catalogue/batch-covers/cancel/+server.ts | 24 + .../refresh-metadata/[slug]/+server.ts | 41 + .../text-gen/content-warnings/+server.ts | 24 + .../api/admin/text-gen/genres/+server.ts | 24 + .../admin/text-gen/genres/apply/+server.ts | 24 + .../admin/text-gen/quality-score/+server.ts | 24 + .../api/admin/text-gen/tagline/+server.ts | 24 + ui/src/routes/books/[slug]/+page.svelte | 4 +- 25 files changed, 1849 insertions(+), 42 deletions(-) create mode 100644 backend/internal/backend/handlers_catalogue.go create mode 100644 ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js create mode 100644 ui/src/routes/admin/catalogue-tools/+page.server.ts create mode 100644 ui/src/routes/admin/catalogue-tools/+page.svelte create mode 100644 ui/src/routes/api/admin/catalogue/batch-covers/+server.ts create mode 100644 ui/src/routes/api/admin/catalogue/batch-covers/cancel/+server.ts create mode 100644 ui/src/routes/api/admin/catalogue/refresh-metadata/[slug]/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/content-warnings/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/genres/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/genres/apply/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/quality-score/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/tagline/+server.ts diff --git a/backend/internal/backend/handlers_catalogue.go b/backend/internal/backend/handlers_catalogue.go new file mode 100644 index 0000000..4a2c4d3 --- /dev/null +++ b/backend/internal/backend/handlers_catalogue.go @@ -0,0 +1,728 @@ +package backend + +// Catalogue enrichment handlers: tagline, genre tagging, content warnings, +// quality scoring, batch cover regeneration, and per-book metadata refresh. +// +// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer). +// All long-running operations support cancellation via r.Context().Done(). +// Batch operations use an in-memory cancel registry (cancelJobs map) so the +// frontend can send a cancel request by job ID. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/libnovel/backend/internal/cfai" + "github.com/libnovel/backend/internal/domain" +) + +// ── Cancel registry ──────────────────────────────────────────────────────── + +// cancelJobsMu guards cancelJobs. +var cancelJobsMu sync.Mutex + +// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch +// job starts and removed when it finishes or is cancelled. +var cancelJobs = map[string]context.CancelFunc{} + +func registerCancelJob(id string, cancel context.CancelFunc) { + cancelJobsMu.Lock() + cancelJobs[id] = cancel + cancelJobsMu.Unlock() +} + +func deregisterCancelJob(id string) { + cancelJobsMu.Lock() + delete(cancelJobs, id) + cancelJobsMu.Unlock() +} + +// ── Tagline ─────────────────────────────────────────────────────────────── + +// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline. +type textGenTaglineRequest struct { + Slug string `json:"slug"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` +} + +// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline. +type textGenTaglineResponse struct { + OldTagline string `json:"old_tagline"` + NewTagline string `json:"new_tagline"` + Model string `json:"model"` +} + +// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline. +// Generates a 1-sentence marketing hook for a book. +func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text generation not configured") + return + } + + var req textGenTaglineRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + system := `You are a copywriter for a web novel platform. ` + + `Given a book's title, genres, and description, write a single punchy tagline ` + + `(one sentence, under 20 words) that hooks a reader. ` + + `Output ONLY the tagline — no quotes, no labels, no explanation.` + + user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s", + meta.Title, + strings.Join(meta.Genres, ", "), + meta.Summary, + ) + + s.deps.Log.Info("admin: text-gen tagline requested", "slug", req.Slug, "model", model) + + result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, + MaxTokens: 64, + }) + if genErr != nil { + s.deps.Log.Error("admin: text-gen tagline failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error()) + return + } + + writeJSON(w, 0, textGenTaglineResponse{ + OldTagline: "", // BookMeta has no tagline field yet — always empty + NewTagline: strings.TrimSpace(result), + Model: string(model), + }) +} + +// ── Genres ──────────────────────────────────────────────────────────────── + +// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres. +type textGenGenresRequest struct { + Slug string `json:"slug"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` +} + +// textGenGenresResponse is returned by POST /api/admin/text-gen/genres. +type textGenGenresResponse struct { + CurrentGenres []string `json:"current_genres"` + ProposedGenres []string `json:"proposed_genres"` + Model string `json:"model"` +} + +// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres. +// Suggests a refined genre list based on the book's description. +func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text generation not configured") + return + } + + var req textGenGenresRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + system := `You are a genre classification expert for a web novel platform. ` + + `Given a book's title and description, return a JSON array of 2–6 genre tags. ` + + `Use only well-known web novel genres such as: ` + + `Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` + + `Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` + + `Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` + + `Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` + + `Example: ["Fantasy","Adventure","Action"]` + + user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s", + meta.Title, + strings.Join(meta.Genres, ", "), + meta.Summary, + ) + + s.deps.Log.Info("admin: text-gen genres requested", "slug", req.Slug, "model", model) + + raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, + MaxTokens: 128, + }) + if genErr != nil { + s.deps.Log.Error("admin: text-gen genres failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error()) + return + } + + proposed := parseStringArrayJSON(raw) + + writeJSON(w, 0, textGenGenresResponse{ + CurrentGenres: meta.Genres, + ProposedGenres: proposed, + Model: string(model), + }) +} + +// handleAdminTextGenApplyGenres handles POST /api/admin/text-gen/genres/apply. +// Persists the confirmed genre list to PocketBase. +func (s *Server) handleAdminTextGenApplyGenres(w http.ResponseWriter, r *http.Request) { + if s.deps.BookWriter == nil { + jsonError(w, http.StatusServiceUnavailable, "book writer not configured") + return + } + + var req struct { + Slug string `json:"slug"` + Genres []string `json:"genres"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + meta.Genres = req.Genres + if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil { + s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err) + jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error()) + return + } + + s.deps.Log.Info("admin: genres applied", "slug", req.Slug, "genres", req.Genres) + writeJSON(w, 0, map[string]any{"updated": true}) +} + +// ── Content warnings ────────────────────────────────────────────────────── + +// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings. +type textGenContentWarningsRequest struct { + Slug string `json:"slug"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` +} + +// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings. +type textGenContentWarningsResponse struct { + Warnings []string `json:"warnings"` + Model string `json:"model"` +} + +// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings. +// Detects mature or sensitive themes in a book's description. +func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text generation not configured") + return + } + + var req textGenContentWarningsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + system := `You are a content moderation assistant for a web novel platform. ` + + `Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` + + `Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` + + `Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` + + `If the book is clean, return an empty array. ` + + `Output ONLY a raw JSON array of strings — no prose, no markdown. ` + + `Example: ["Violence","Dark Themes"]` + + user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s", + meta.Title, + strings.Join(meta.Genres, ", "), + meta.Summary, + ) + + s.deps.Log.Info("admin: text-gen content-warnings requested", "slug", req.Slug, "model", model) + + raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, + MaxTokens: 128, + }) + if genErr != nil { + s.deps.Log.Error("admin: text-gen content-warnings failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error()) + return + } + + warnings := parseStringArrayJSON(raw) + + writeJSON(w, 0, textGenContentWarningsResponse{ + Warnings: warnings, + Model: string(model), + }) +} + +// ── Quality score ───────────────────────────────────────────────────────── + +// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score. +type textGenQualityScoreRequest struct { + Slug string `json:"slug"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` +} + +// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score. +type textGenQualityScoreResponse struct { + Score int `json:"score"` // 1–5 + Feedback string `json:"feedback"` // brief reasoning + Model string `json:"model"` +} + +// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score. +// Rates the book description quality on a 1–5 scale with brief feedback. +func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text generation not configured") + return + } + + var req textGenQualityScoreRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + system := `You are a book description quality reviewer for a web novel platform. ` + + `Rate the provided description on a scale of 1–5 where: ` + + `1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` + + `Respond with ONLY a JSON object: {"score": <1-5>, "feedback": ""}. ` + + `No markdown, no extra text.` + + user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s", + meta.Title, + strings.Join(meta.Genres, ", "), + meta.Summary, + ) + + s.deps.Log.Info("admin: text-gen quality-score requested", "slug", req.Slug, "model", model) + + raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, + MaxTokens: 128, + }) + if genErr != nil { + s.deps.Log.Error("admin: text-gen quality-score failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error()) + return + } + + var parsed struct { + Score int `json:"score"` + Feedback string `json:"feedback"` + } + // Strip markdown fences if any. + clean := extractJSONObject(raw) + if err := json.Unmarshal([]byte(clean), &parsed); err != nil { + // Fallback: try to extract a digit. + parsed.Score = 0 + for _, ch := range raw { + if ch >= '1' && ch <= '5' { + parsed.Score = int(ch - '0') + break + } + } + parsed.Feedback = strings.TrimSpace(raw) + } + + writeJSON(w, 0, textGenQualityScoreResponse{ + Score: parsed.Score, + Feedback: parsed.Feedback, + Model: string(model), + }) +} + +// ── Batch cover regeneration ────────────────────────────────────────────── + +// batchCoverEvent is one SSE event emitted during batch cover regeneration. +type batchCoverEvent struct { + // JobID is the opaque identifier clients use to cancel this job. + JobID string `json:"job_id,omitempty"` + Done int `json:"done"` + Total int `json:"total"` + Slug string `json:"slug,omitempty"` + Error string `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` + Finish bool `json:"finish,omitempty"` +} + +// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers. +// +// Streams SSE events as it generates covers for every book that has no cover +// stored in MinIO. Each event carries progress info. The final event has Finish=true. +// +// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel +// with body {"job_id":"..."}. +func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil || s.deps.ImageGen == nil { + jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured") + return + } + if s.deps.CoverStore == nil { + jsonError(w, http.StatusServiceUnavailable, "cover store not configured") + return + } + + var reqBody struct { + Model string `json:"model"` + NumSteps int `json:"num_steps"` + Width int `json:"width"` + Height int `json:"height"` + } + // Body is optional — defaults used if absent. + json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck + + books, err := s.deps.BookReader.ListBooks(r.Context()) + if err != nil { + jsonError(w, http.StatusInternalServerError, "list books: "+err.Error()) + return + } + + // Generate a unique job ID. + jobID := randomHex(8) + ctx, cancel := context.WithCancel(r.Context()) + registerCancelJob(jobID, cancel) + defer deregisterCancelJob(jobID) + defer cancel() + + // SSE headers. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + flusher, canFlush := w.(http.Flusher) + + sseWrite := func(evt batchCoverEvent) { + b, _ := json.Marshal(evt) + fmt.Fprintf(w, "data: %s\n\n", b) + if canFlush { + flusher.Flush() + } + } + + total := len(books) + done := 0 + + // Send initial event with jobID so frontend can store it for cancellation. + sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total}) + + for _, book := range books { + if ctx.Err() != nil { + break + } + + // Check if cover already exists. + hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug) + if hasCover { + done++ + sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true}) + continue + } + + // Build a prompt from the book metadata. + prompt := buildCoverPrompt(book) + + // Generate the image via CF AI. + imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{ + Prompt: prompt, + NumSteps: reqBody.NumSteps, + Width: reqBody.Width, + Height: reqBody.Height, + }) + if genErr != nil { + done++ + s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr) + sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()}) + continue + } + + // Save to CoverStore. + if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil { + done++ + s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr) + sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()}) + continue + } + + done++ + s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug) + sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug}) + } + + sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true}) +} + +// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel. +// Cancels an in-progress batch cover job by its job ID. +func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) { + var req struct { + JobID string `json:"job_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" { + jsonError(w, http.StatusBadRequest, "job_id is required") + return + } + + cancelJobsMu.Lock() + cancel, ok := cancelJobs[req.JobID] + cancelJobsMu.Unlock() + + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID)) + return + } + cancel() + s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID) + writeJSON(w, 0, map[string]any{"cancelled": true}) +} + +// ── Refresh metadata (per-book) ──────────────────────────────────────────── + +// refreshMetadataEvent is one SSE event during per-book metadata refresh. +type refreshMetadataEvent struct { + Step string `json:"step"` // "description" | "tagline" | "cover" + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}. +// +// Runs description → tagline → cover generation in sequence for a single book +// and streams SSE progress. Interruptable via client disconnect (r.Context()). +func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", slug)) + return + } + + // SSE headers. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + flusher, canFlush := w.(http.Flusher) + + sseWrite := func(evt refreshMetadataEvent) { + b, _ := json.Marshal(evt) + fmt.Fprintf(w, "data: %s\n\n", b) + if canFlush { + flusher.Flush() + } + } + + ctx := r.Context() + + // Step 1 — description. + if s.deps.TextGen != nil { + if ctx.Err() == nil { + newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{ + Model: cfai.DefaultTextModel, + Messages: []cfai.TextMessage{ + {Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`}, + {Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)}, + }, + MaxTokens: 512, + }) + if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil { + meta.Summary = strings.TrimSpace(newDesc) + if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil { + sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()}) + } else { + sseWrite(refreshMetadataEvent{Step: "description"}) + } + } else if genErr != nil { + sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()}) + } + } + } + + // Step 2 — cover. + if s.deps.ImageGen != nil && s.deps.CoverStore != nil { + if ctx.Err() == nil { + prompt := buildCoverPrompt(meta) + imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt}) + if genErr == nil { + if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil { + sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()}) + } else { + sseWrite(refreshMetadataEvent{Step: "cover"}) + } + } else { + sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()}) + } + } + } + + sseWrite(refreshMetadataEvent{Step: "done", Done: true}) +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +// parseStringArrayJSON extracts a JSON string array from model output, +// tolerating markdown fences and surrounding prose. +func parseStringArrayJSON(raw string) []string { + s := raw + if idx := strings.Index(s, "```json"); idx >= 0 { + s = s[idx+7:] + } else if idx := strings.Index(s, "```"); idx >= 0 { + s = s[idx+3:] + } + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + start := strings.Index(s, "[") + end := strings.LastIndex(s, "]") + if start < 0 || end <= start { + return nil + } + s = s[start : end+1] + var out []string + json.Unmarshal([]byte(s), &out) //nolint:errcheck + return out +} + +// extractJSONObject finds the first {...} object in a string. +func extractJSONObject(raw string) string { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return raw + } + return raw[start : end+1] +} + +// buildCoverPrompt constructs a prompt string for cover generation from a book. +func buildCoverPrompt(meta domain.BookMeta) string { + parts := []string{"book cover art"} + if meta.Title != "" { + parts = append(parts, "titled \""+meta.Title+"\"") + } + if len(meta.Genres) > 0 { + parts = append(parts, strings.Join(meta.Genres, ", ")+" genre") + } + if meta.Summary != "" { + summary := meta.Summary + if len(summary) > 200 { + summary = summary[:200] + } + parts = append(parts, summary) + } + return strings.Join(parts, ", ") +} + +// randomHex returns a random hex string of n bytes. +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 8484f5e..a4c6704 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -204,6 +204,16 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription) mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription) + // Admin catalogue enrichment endpoints + mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline) + mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres) + mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres) + mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings) + mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore) + mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers) + mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel) + mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata) + // Admin data repair endpoints mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters) diff --git a/ui/messages/en.json b/ui/messages/en.json index 62e21ea..9f23348 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -402,6 +402,7 @@ "admin_nav_changelog": "Changelog", "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", + "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Errors", "admin_nav_analytics": "Analytics", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 0daf8f5..02b69d3 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -402,7 +402,7 @@ "admin_nav_changelog": "Modifications", "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", - "admin_nav_feedback": "Retours", + "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_errors": "Erreurs", "admin_nav_analytics": "Analytique", "admin_nav_logs": "Journaux", diff --git a/ui/messages/id.json b/ui/messages/id.json index 8daae79..bf9254c 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -402,7 +402,7 @@ "admin_nav_changelog": "Perubahan", "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", - "admin_nav_feedback": "Masukan", + "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_errors": "Kesalahan", "admin_nav_analytics": "Analitik", "admin_nav_logs": "Log", diff --git a/ui/messages/pt.json b/ui/messages/pt.json index f484cdb..eb9e23f 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -402,7 +402,7 @@ "admin_nav_changelog": "Alterações", "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", - "admin_nav_feedback": "Feedback", + "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_errors": "Erros", "admin_nav_analytics": "Análise", "admin_nav_logs": "Logs", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index b055eef..5a933cc 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -402,7 +402,7 @@ "admin_nav_changelog": "Изменения", "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", - "admin_nav_feedback": "Отзывы", + "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_errors": "Ошибки", "admin_nav_analytics": "Аналитика", "admin_nav_logs": "Логи", diff --git a/ui/src/lib/paraglide/messages.js b/ui/src/lib/paraglide/messages.js index 83a0d9d..608d20d 100644 --- a/ui/src/lib/paraglide/messages.js +++ b/ui/src/lib/paraglide/messages.js @@ -1,2 +1,4 @@ /* eslint-disable */ -export * from './messages/_index.js' \ No newline at end of file +export * from './messages/_index.js' +// enabling auto-import by exposing all messages as m +export * as m from './messages/_index.js' \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 58c90b1..969c01b 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -373,6 +373,7 @@ 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_catalogue_tools.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_catalogue_tools.js b/ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js new file mode 100644 index 0000000..12d8888 --- /dev/null +++ b/ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Admin_Nav_Catalogue_ToolsInputs */ + +const en_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Catalogue Tools`) +}; + +const ru_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Catalogue Tools`) +}; + +const id_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Catalogue Tools`) +}; + +const pt_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Catalogue Tools`) +}; + +const fr_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Catalogue Tools`) +}; + +/** +* | output | +* | --- | +* | "Catalogue Tools" | +* +* @param {Admin_Nav_Catalogue_ToolsInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ +export const admin_nav_catalogue_tools = /** @type {((inputs?: Admin_Nav_Catalogue_ToolsInputs, 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_catalogue_tools(inputs) + if (locale === "ru") return ru_admin_nav_catalogue_tools(inputs) + if (locale === "id") return id_admin_nav_catalogue_tools(inputs) + if (locale === "pt") return pt_admin_nav_catalogue_tools(inputs) + return fr_admin_nav_catalogue_tools(inputs) +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_feedback.js b/ui/src/lib/paraglide/messages/admin_nav_feedback.js index 5f6a1c7..0ad1a5f 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_feedback.js +++ b/ui/src/lib/paraglide/messages/admin_nav_feedback.js @@ -9,21 +9,17 @@ const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => L return /** @type {LocalizedString} */ (`Feedback`) }; -const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { - return /** @type {LocalizedString} */ (`Отзывы`) -}; +/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ +const ru_admin_nav_feedback = en_admin_nav_feedback; -const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { - return /** @type {LocalizedString} */ (`Masukan`) -}; +/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ +const id_admin_nav_feedback = en_admin_nav_feedback; -const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { - return /** @type {LocalizedString} */ (`Feedback`) -}; +/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ +const pt_admin_nav_feedback = en_admin_nav_feedback; -const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { - return /** @type {LocalizedString} */ (`Retours`) -}; +/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ +const fr_admin_nav_feedback = en_admin_nav_feedback; /** * | output | diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index 77051b4..e0bd6b1 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -8,7 +8,8 @@ { 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/text-gen', label: () => m.admin_nav_text_gen() } + { href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }, + { href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() } ]; const externalLinks = [ diff --git a/ui/src/routes/admin/catalogue-tools/+page.server.ts b/ui/src/routes/admin/catalogue-tools/+page.server.ts new file mode 100644 index 0000000..4cc1ad0 --- /dev/null +++ b/ui/src/routes/admin/catalogue-tools/+page.server.ts @@ -0,0 +1,24 @@ +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; +} + +export const load: PageServerLoad = async () => { + // Parent layout already guards admin role. + let imgModels: ImageModelInfo[] = []; + try { + const res = await backendFetch('/api/admin/image-gen/models'); + if (res.ok) { + const data = await res.json(); + imgModels = (data.models ?? []) as ImageModelInfo[]; + } + } catch (e) { + log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) }); + } + return { imgModels }; +}; diff --git a/ui/src/routes/admin/catalogue-tools/+page.svelte b/ui/src/routes/admin/catalogue-tools/+page.svelte new file mode 100644 index 0000000..bc1c311 --- /dev/null +++ b/ui/src/routes/admin/catalogue-tools/+page.svelte @@ -0,0 +1,257 @@ + + + + Catalogue Tools — Admin + + +
+
+

Catalogue Tools

+

Bulk AI operations for your book catalogue.

+
+ + +
+

Batch Cover Generation

+

+ Generates AI covers for every book that has no cover stored in MinIO. + Books with existing covers are skipped. The job can be cancelled at any time. +

+ + +
+ {#if imgModels.length > 0} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {#if running && jobID} + + {/if} +
+ + {#if error} +

{error}

+ {/if} + + + {#if total > 0 || running} +
+
+ + {done} / {total} books processed + {#if finished} — done{/if} + + {progress}% +
+
+
+
+
+ {/if} + + + {#if events.length > 0} +
+
+

Activity log (newest first)

+
+
+ {#each events as evt} +
+ {#if evt.error} + + {evt.slug} + {evt.error} + {:else if evt.skipped} + + {evt.slug} + skipped (has cover) + {:else} + + {evt.slug} + generated + {/if} +
+ {/each} +
+
+ {/if} +
+
diff --git a/ui/src/routes/admin/image-gen/+page.svelte b/ui/src/routes/admin/image-gen/+page.svelte index 49d1089..a57b0e3 100644 --- a/ui/src/routes/admin/image-gen/+page.svelte +++ b/ui/src/routes/admin/image-gen/+page.svelte @@ -61,7 +61,7 @@ }); // ── Book autocomplete ──────────────────────────────────────────────────────── - const books = data.books as BookSummary[]; + const books: BookSummary[] = data.books ?? []; let slugInput = $state(''); let slugFocused = $state(false); let selectedBook = $state(null); @@ -144,7 +144,7 @@ let saveSuccess = $state(false); // ── Model helpers ──────────────────────────────────────────────────────────── - const models = data.models as ImageModelInfo[]; + 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'))); @@ -191,6 +191,54 @@ prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`; } + // ── 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(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); @@ -527,28 +575,39 @@ -
+
-
+
{#if selectedBook?.summary} - {/if} - + {#if presets.length > 0} + + {/if}
+ + {#if showPresets && presets.length > 0} +
+ {#each presets as p} +
+ + +
+ {/each} +
+ {/if} + + +
+ + +
diff --git a/ui/src/routes/admin/text-gen/+page.svelte b/ui/src/routes/admin/text-gen/+page.svelte index 6fa95d5..013c065 100644 --- a/ui/src/routes/admin/text-gen/+page.svelte +++ b/ui/src/routes/admin/text-gen/+page.svelte @@ -5,15 +5,18 @@ let { data }: { data: PageData } = $props(); - const models = data.models as TextModelInfo[]; - const books = data.books as BookSummary[]; + // Server data is static per page load — intentional one-time snapshot. + // svelte-ignore state_referenced_locally + const models: TextModelInfo[] = data.models ?? []; + // svelte-ignore state_referenced_locally + const books: BookSummary[] = data.books ?? []; // ── Config persistence ─────────────────────────────────────────────────────── - const CONFIG_KEY = 'admin_text_gen_config_v1'; + const CONFIG_KEY = 'admin_text_gen_config_v2'; interface SavedConfig { selectedModel: string; - activeTab: 'chapters' | 'description'; + activeTab: string; chPattern: string; dInstructions: string; } @@ -35,8 +38,8 @@ const saved = loadConfig(); // ── Shared ──────────────────────────────────────────────────────────────────── - type ActiveTab = 'chapters' | 'description'; - let activeTab = $state(saved.activeTab ?? 'chapters'); + type ActiveTab = 'chapters' | 'description' | 'tagline' | 'genres' | 'warnings' | 'quality'; + let activeTab = $state((saved.activeTab as ActiveTab) ?? 'chapters'); let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? '')); let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null); @@ -326,6 +329,142 @@ dApplying = false; } } + + // ── Tagline state ───────────────────────────────────────────────────────────── + let tAC = makeBookAC(); + let tSlug = $state(''); + let tGenerating = $state(false); + let tError = $state(''); + let tResult = $state(''); + let tUsedModel = $state(''); + + let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating); + + function selectTBook(b: BookSummary) { tSlug = b.slug; tAC.inputVal = b.slug; tAC.focused = false; } + function onTSlugInput() { tSlug = tAC.inputVal; } + + async function generateTagline() { + if (!tCanGenerate) return; + tGenerating = true; tError = ''; tResult = ''; + try { + const res = await fetch('/api/admin/text-gen/tagline', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: tSlug.trim(), model: selectedModel }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; } + tResult = body.new_tagline ?? ''; + tUsedModel = body.model ?? ''; + } catch { tError = 'Network error.'; } finally { tGenerating = false; } + } + + // ── Genres state ────────────────────────────────────────────────────────────── + let gAC = makeBookAC(); + let gSlug = $state(''); + let gGenerating = $state(false); + let gError = $state(''); + let gCurrent = $state([]); + let gProposed = $state([]); + let gEdited = $state([]); + let gUsedModel = $state(''); + let gApplying = $state(false); + let gApplyError = $state(''); + let gApplySuccess = $state(false); + + let gCanGenerate = $derived(gSlug.trim().length > 0 && !gGenerating); + let gCanApply = $derived(gEdited.length > 0 && !gApplying); + + function selectGBook(b: BookSummary) { gSlug = b.slug; gAC.inputVal = b.slug; gAC.focused = false; } + function onGSlugInput() { gSlug = gAC.inputVal; } + + async function generateGenres() { + if (!gCanGenerate) return; + gGenerating = true; gError = ''; gCurrent = []; gProposed = []; gEdited = []; gApplySuccess = false; + try { + const res = await fetch('/api/admin/text-gen/genres', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: gSlug.trim(), model: selectedModel }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { gError = body.error ?? `Error ${res.status}`; return; } + gCurrent = body.current_genres ?? []; + gProposed = body.proposed_genres ?? []; + gEdited = [...gProposed]; + gUsedModel = body.model ?? ''; + } catch { gError = 'Network error.'; } finally { gGenerating = false; } + } + + async function applyGenres() { + if (!gCanApply) return; + gApplying = true; gApplyError = ''; gApplySuccess = false; + try { + const res = await fetch('/api/admin/text-gen/genres/apply', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: gSlug.trim(), genres: gEdited.filter(Boolean) }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { gApplyError = body.error ?? `Error ${res.status}`; return; } + gApplySuccess = true; + } catch { gApplyError = 'Network error.'; } finally { gApplying = false; } + } + + // ── Content warnings state ──────────────────────────────────────────────────── + let wAC = makeBookAC(); + let wSlug = $state(''); + let wGenerating = $state(false); + let wError = $state(''); + let wWarnings = $state([]); + let wUsedModel = $state(''); + + let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating); + + function selectWBook(b: BookSummary) { wSlug = b.slug; wAC.inputVal = b.slug; wAC.focused = false; } + function onWSlugInput() { wSlug = wAC.inputVal; } + + async function generateWarnings() { + if (!wCanGenerate) return; + wGenerating = true; wError = ''; wWarnings = []; + try { + const res = await fetch('/api/admin/text-gen/content-warnings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: wSlug.trim(), model: selectedModel }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { wError = body.error ?? `Error ${res.status}`; return; } + wWarnings = body.warnings ?? []; + wUsedModel = body.model ?? ''; + } catch { wError = 'Network error.'; } finally { wGenerating = false; } + } + + // ── Quality score state ─────────────────────────────────────────────────────── + let qAC = makeBookAC(); + let qSlug = $state(''); + let qGenerating = $state(false); + let qError = $state(''); + let qScore = $state(0); + let qFeedback = $state(''); + let qUsedModel = $state(''); + + let qCanGenerate = $derived(qSlug.trim().length > 0 && !qGenerating); + + function selectQBook(b: BookSummary) { qSlug = b.slug; qAC.inputVal = b.slug; qAC.focused = false; } + function onQSlugInput() { qSlug = qAC.inputVal; } + + async function generateQualityScore() { + if (!qCanGenerate) return; + qGenerating = true; qError = ''; qScore = 0; qFeedback = ''; + try { + const res = await fetch('/api/admin/text-gen/quality-score', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: qSlug.trim(), model: selectedModel }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { qError = body.error ?? `Error ${res.status}`; return; } + qScore = body.score ?? 0; + qFeedback = body.feedback ?? ''; + qUsedModel = body.model ?? ''; + } catch { qError = 'Network error.'; } finally { qGenerating = false; } + } @@ -361,16 +500,23 @@
-
- {#each (['chapters', 'description'] as const) as t} +
+ {#each ([ + ['chapters', 'Chapter Names'], + ['description', 'Description'], + ['tagline', 'Tagline'], + ['genres', 'Genres'], + ['warnings', 'Warnings'], + ['quality', 'Quality'], + ] as const) as [t, label]} {/each}
@@ -706,4 +852,305 @@
{/if} + + + {#if activeTab === 'tagline'} +
+
+
+ +
+ (tAC.focused = true)} onblur={() => setTimeout(() => { tAC.focused = 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 tAC.focused && tAC.suggestions.length > 0} +
    + {#each tAC.suggestions as b} + +
  • selectTBook(b)} + class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"> +

    {b.title}

    +

    {b.slug}

    +
  • + {/each} +
+ {/if} +
+
+ + {#if tError}

{tError}

{/if} +
+
+ {#if tResult} +
+

+ Tagline{#if tUsedModel} · {tUsedModel.split('/').pop()}{/if} +

+
+

{tResult}

+
+ +
+ {:else if tGenerating} +
+ + + + +
+ {:else} +
+

Tagline will appear here

+
+ {/if} +
+
+ {/if} + + + {#if activeTab === 'genres'} +
+
+
+ +
+ (gAC.focused = true)} onblur={() => setTimeout(() => { gAC.focused = 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 gAC.focused && gAC.suggestions.length > 0} +
    + {#each gAC.suggestions as b} + +
  • selectGBook(b)} + class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"> +

    {b.title}

    +

    {b.slug}

    +
  • + {/each} +
+ {/if} +
+
+ + {#if gError}

{gError}

{/if} +
+
+ {#if gProposed.length > 0} +
+ {#if gCurrent.length > 0} +
+

Current genres

+
+ {#each gCurrent as g} + {g} + {/each} +
+
+ {/if} +
+

+ Proposed{#if gUsedModel} · {gUsedModel.split('/').pop()}{/if} +

+
+ {#each gEdited as g, i} + + {/each} + +
+
+
+ + +
+ {#if gApplyError}

{gApplyError}

{/if} + {#if gApplySuccess}

Genres saved successfully.

{/if} +
+ {:else if gGenerating} +
+ + + + +
+ {:else} +
+

Genre suggestions will appear here

+
+ {/if} +
+
+ {/if} + + + {#if activeTab === 'warnings'} +
+
+
+ +
+ (wAC.focused = true)} onblur={() => setTimeout(() => { wAC.focused = 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 wAC.focused && wAC.suggestions.length > 0} +
    + {#each wAC.suggestions as b} + +
  • selectWBook(b)} + class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"> +

    {b.title}

    +

    {b.slug}

    +
  • + {/each} +
+ {/if} +
+
+ + {#if wError}

{wError}

{/if} +
+
+ {#if wWarnings.length > 0} +
+

+ Detected warnings{#if wUsedModel} · {wUsedModel.split('/').pop()}{/if} +

+
+ {#each wWarnings as w} + {w} + {/each} +
+ +
+ {:else if wWarnings.length === 0 && wUsedModel} +
+

No content warnings detected.

+
+ {:else if wGenerating} +
+ + + + +
+ {:else} +
+

Warnings will appear here

+
+ {/if} +
+
+ {/if} + + + {#if activeTab === 'quality'} +
+
+
+ +
+ (qAC.focused = true)} onblur={() => setTimeout(() => { qAC.focused = 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 qAC.focused && qAC.suggestions.length > 0} +
    + {#each qAC.suggestions as b} + +
  • selectQBook(b)} + class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"> +

    {b.title}

    +

    {b.slug}

    +
  • + {/each} +
+ {/if} +
+
+ + {#if qError}

{qError}

{/if} +
+
+ {#if qScore > 0} +
+

+ Quality score{#if qUsedModel} · {qUsedModel.split('/').pop()}{/if} +

+
+
+ {qScore} +
+ {#each [1,2,3,4,5] as star} + + {/each} +
+
+ {#if qFeedback} +

{qFeedback}

+ {/if} +
+ +
+ {:else if qGenerating} +
+ + + + +
+ {:else} +
+

Quality score will appear here

+
+ {/if} +
+
+ {/if} diff --git a/ui/src/routes/api/admin/catalogue/batch-covers/+server.ts b/ui/src/routes/api/admin/catalogue/batch-covers/+server.ts new file mode 100644 index 0000000..c8a74fb --- /dev/null +++ b/ui/src/routes/api/admin/catalogue/batch-covers/+server.ts @@ -0,0 +1,43 @@ +/** + * POST /api/admin/catalogue/batch-covers + * + * Admin-only SSE proxy to the Go backend's batch cover generation endpoint. + * Pipes the stream straight through so the browser can consume it without buffering. + */ +import { 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/catalogue/batch-covers', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/catalogue/batch-covers', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + return new Response(JSON.stringify(data), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(res.body, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/ui/src/routes/api/admin/catalogue/batch-covers/cancel/+server.ts b/ui/src/routes/api/admin/catalogue/batch-covers/cancel/+server.ts new file mode 100644 index 0000000..207fc1a --- /dev/null +++ b/ui/src/routes/api/admin/catalogue/batch-covers/cancel/+server.ts @@ -0,0 +1,24 @@ +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/catalogue/batch-covers/cancel', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/catalogue/batch-covers/cancel', '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 }); +}; diff --git a/ui/src/routes/api/admin/catalogue/refresh-metadata/[slug]/+server.ts b/ui/src/routes/api/admin/catalogue/refresh-metadata/[slug]/+server.ts new file mode 100644 index 0000000..e868199 --- /dev/null +++ b/ui/src/routes/api/admin/catalogue/refresh-metadata/[slug]/+server.ts @@ -0,0 +1,41 @@ +/** + * POST /api/admin/catalogue/refresh-metadata/[slug] + * + * Admin-only SSE proxy — re-generates description + cover for a single book. + */ +import { 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 ({ params, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + let res: Response; + try { + res = await backendFetch(`/api/admin/catalogue/refresh-metadata/${params.slug}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }); + } catch (e) { + log.error('admin/catalogue/refresh-metadata', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + return new Response(JSON.stringify(data), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(res.body, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/ui/src/routes/api/admin/text-gen/content-warnings/+server.ts b/ui/src/routes/api/admin/text-gen/content-warnings/+server.ts new file mode 100644 index 0000000..bcc3391 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/content-warnings/+server.ts @@ -0,0 +1,24 @@ +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/content-warnings', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/content-warnings', '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 }); +}; diff --git a/ui/src/routes/api/admin/text-gen/genres/+server.ts b/ui/src/routes/api/admin/text-gen/genres/+server.ts new file mode 100644 index 0000000..8d1b2e5 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/genres/+server.ts @@ -0,0 +1,24 @@ +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/genres', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/genres', '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 }); +}; diff --git a/ui/src/routes/api/admin/text-gen/genres/apply/+server.ts b/ui/src/routes/api/admin/text-gen/genres/apply/+server.ts new file mode 100644 index 0000000..6700572 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/genres/apply/+server.ts @@ -0,0 +1,24 @@ +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/genres/apply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/genres/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 }); +}; diff --git a/ui/src/routes/api/admin/text-gen/quality-score/+server.ts b/ui/src/routes/api/admin/text-gen/quality-score/+server.ts new file mode 100644 index 0000000..87e95cd --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/quality-score/+server.ts @@ -0,0 +1,24 @@ +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/quality-score', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/quality-score', '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 }); +}; diff --git a/ui/src/routes/api/admin/text-gen/tagline/+server.ts b/ui/src/routes/api/admin/text-gen/tagline/+server.ts new file mode 100644 index 0000000..3e97f65 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/tagline/+server.ts @@ -0,0 +1,24 @@ +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/tagline', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/tagline', '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 }); +}; diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index aeff74e..c55bb40 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -1031,7 +1031,7 @@
@@ -1133,7 +1133,7 @@