feat: catalogue enrichment — tagline, genres, warnings, quality score, batch covers
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 <noreply@anthropic.com>
This commit is contained in:
728
backend/internal/backend/handlers_catalogue.go
Normal file
728
backend/internal/backend/handlers_catalogue.go
Normal file
@@ -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": "<one sentence explanation>"}. ` +
|
||||
`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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Логи",
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
/* eslint-disable */
|
||||
export * from './messages/_index.js'
|
||||
// enabling auto-import by exposing all messages as m
|
||||
export * as m from './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'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js
Normal file
@@ -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<Admin_Nav_Catalogue_ToolsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((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)
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
24
ui/src/routes/admin/catalogue-tools/+page.server.ts
Normal file
24
ui/src/routes/admin/catalogue-tools/+page.server.ts
Normal file
@@ -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 };
|
||||
};
|
||||
257
ui/src/routes/admin/catalogue-tools/+page.svelte
Normal file
257
ui/src/routes/admin/catalogue-tools/+page.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const imgModels = data.imgModels ?? [];
|
||||
|
||||
// ── Config persistence ────────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_catalogue_tools_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
imgModel: string;
|
||||
numSteps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify({ imgModel, numSteps, width, height }));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imgModel = $state(saved.imgModel ?? (imgModels[0]?.id ?? ''));
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let width = $state(saved.width ?? 0);
|
||||
let height = $state(saved.height ?? 0);
|
||||
|
||||
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
|
||||
|
||||
// ── Batch covers ──────────────────────────────────────────────────────────────
|
||||
let running = $state(false);
|
||||
let jobID = $state('');
|
||||
let done = $state(0);
|
||||
let total = $state(0);
|
||||
let events = $state<{ slug: string; skipped?: boolean; error?: string }[]>([]);
|
||||
let finished = $state(false);
|
||||
let error = $state('');
|
||||
let cancelling = $state(false);
|
||||
|
||||
let progress = $derived(total > 0 ? Math.round((done / total) * 100) : 0);
|
||||
|
||||
async function startBatch() {
|
||||
running = true; finished = false; error = ''; done = 0; total = 0; events = []; jobID = ''; cancelling = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/catalogue/batch-covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: imgModel || undefined,
|
||||
num_steps: numSteps || undefined,
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
error = body.error ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
outer: while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
let evt: {
|
||||
job_id?: string;
|
||||
done?: number;
|
||||
total?: number;
|
||||
slug?: string;
|
||||
skipped?: boolean;
|
||||
error?: string;
|
||||
finish?: boolean;
|
||||
};
|
||||
try { evt = JSON.parse(payload); } catch { continue; }
|
||||
|
||||
if (evt.job_id) jobID = evt.job_id;
|
||||
if (evt.total != null) total = evt.total;
|
||||
if (evt.done != null) done = evt.done;
|
||||
if (evt.finish) { finished = true; running = false; break outer; }
|
||||
if (evt.slug) {
|
||||
events = [{ slug: evt.slug, skipped: evt.skipped, error: evt.error }, ...events].slice(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBatch() {
|
||||
if (!jobID) return;
|
||||
cancelling = true;
|
||||
try {
|
||||
await fetch('/api/admin/catalogue/batch-covers/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_id: jobID })
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
cancelling = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Catalogue Tools — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8 max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue Tools</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">Bulk AI operations for your book catalogue.</p>
|
||||
</div>
|
||||
|
||||
<!-- Batch cover generation -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Batch Cover Generation</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Config -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 bg-(--color-surface) border border-(--color-border) rounded-xl p-4">
|
||||
{#if imgModels.length > 0}
|
||||
<div class="col-span-2 sm:col-span-4 space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="img-model">Image model</label>
|
||||
<select id="img-model" bind:value={imgModel}
|
||||
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 imgModels as m}
|
||||
<option value={m.id}>{m.label} — {m.provider}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="steps">Steps</label>
|
||||
<input id="steps" type="number" bind:value={numSteps} min="1" max="50"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="width">Width <span class="font-normal">(0=default)</span></label>
|
||||
<input id="width" type="number" bind:value={width} min="0" step="64"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="height">Height <span class="font-normal">(0=default)</span></label>
|
||||
<input id="height" type="number" bind:value={height} min="0" step="64"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={startBatch}
|
||||
disabled={running}
|
||||
class="px-6 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 gap-2"
|
||||
>
|
||||
{#if running}
|
||||
<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>
|
||||
Running…
|
||||
{:else}
|
||||
Start batch
|
||||
{/if}
|
||||
</button>
|
||||
{#if running && jobID}
|
||||
<button
|
||||
onclick={cancelBatch}
|
||||
disabled={cancelling}
|
||||
class="px-5 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Progress -->
|
||||
{#if total > 0 || running}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-(--color-muted)">
|
||||
{done} / {total} books processed
|
||||
{#if finished} — done{/if}
|
||||
</span>
|
||||
<span class="text-(--color-muted)">{progress}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-(--color-surface-2) rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {finished ? 'bg-green-500' : 'bg-(--color-brand)'}"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Event log -->
|
||||
{#if events.length > 0}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-(--color-border)">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Activity log (newest first)</p>
|
||||
</div>
|
||||
<div class="max-h-80 overflow-y-auto divide-y divide-(--color-border)">
|
||||
{#each events as evt}
|
||||
<div class="px-4 py-2 flex items-center gap-3 text-sm">
|
||||
{#if evt.error}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-danger) shrink-0"></span>
|
||||
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
|
||||
<span class="text-(--color-danger) text-xs truncate">{evt.error}</span>
|
||||
{:else if evt.skipped}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-surface-2) shrink-0"></span>
|
||||
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
|
||||
<span class="text-xs text-(--color-muted)">skipped (has cover)</span>
|
||||
{:else}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>
|
||||
<span class="font-mono text-(--color-text)">{evt.slug}</span>
|
||||
<span class="text-xs text-green-400">generated</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<BookSummary | null>(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<StylePreset[]>(loadPresets());
|
||||
let newPresetName = $state('');
|
||||
let showPresets = $state(false);
|
||||
|
||||
function saveCurrentAsPreset() {
|
||||
const name = newPresetName.trim();
|
||||
if (!name || !prompt.trim()) return;
|
||||
const existing = presets.findIndex((p) => p.name === name);
|
||||
const updated = [...presets];
|
||||
if (existing >= 0) updated[existing] = { name, prompt };
|
||||
else updated.push({ name, prompt });
|
||||
presets = updated;
|
||||
savePresets(updated);
|
||||
newPresetName = '';
|
||||
}
|
||||
|
||||
function applyPreset(p: StylePreset) {
|
||||
prompt = p.prompt;
|
||||
showPresets = false;
|
||||
}
|
||||
|
||||
function deletePreset(name: string) {
|
||||
const updated = presets.filter((p) => p.name !== name);
|
||||
presets = updated;
|
||||
savePresets(updated);
|
||||
}
|
||||
|
||||
// ── Reference image handling ─────────────────────────────────────────────────
|
||||
let dragOver = $state(false);
|
||||
|
||||
@@ -527,28 +575,39 @@
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||||
Prompt
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{#if selectedBook?.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
|
||||
Inject description
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<button onclick={applyTemplate} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Use template
|
||||
</button>
|
||||
{#if presets.length > 0}
|
||||
<button onclick={() => (showPresets = !showPresets)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Presets ({presets.length})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showPresets && presets.length > 0}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
|
||||
{#each presets as p}
|
||||
<div class="flex items-center gap-2 group">
|
||||
<button onclick={() => applyPreset(p)} class="flex-1 text-left text-sm text-(--color-text) hover:text-(--color-brand) transition-colors truncate" title={p.prompt}>{p.name}</button>
|
||||
<button onclick={() => deletePreset(p.name)} class="text-xs text-(--color-muted) hover:text-(--color-danger) transition-colors opacity-0 group-hover:opacity-100">delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
bind:value={prompt}
|
||||
@@ -556,6 +615,15 @@
|
||||
placeholder="Describe the image to generate…"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
|
||||
class="flex-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-(--color-text) text-xs placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||||
<button onclick={saveCurrentAsPreset} disabled={!newPresetName.trim() || !prompt.trim()}
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
Save preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference image drop zone -->
|
||||
|
||||
@@ -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<ActiveTab>(saved.activeTab ?? 'chapters');
|
||||
type ActiveTab = 'chapters' | 'description' | 'tagline' | 'genres' | 'warnings' | 'quality';
|
||||
let activeTab = $state<ActiveTab>((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<string[]>([]);
|
||||
let gProposed = $state<string[]>([]);
|
||||
let gEdited = $state<string[]>([]);
|
||||
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<string[]>([]);
|
||||
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; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -361,16 +500,23 @@
|
||||
</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}
|
||||
<div class="flex flex-wrap gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each ([
|
||||
['chapters', 'Chapter Names'],
|
||||
['description', 'Description'],
|
||||
['tagline', 'Tagline'],
|
||||
['genres', 'Genres'],
|
||||
['warnings', 'Warnings'],
|
||||
['quality', 'Quality'],
|
||||
] as const) as [t, label]}
|
||||
<button
|
||||
onclick={() => (activeTab = t)}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
class="px-3 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'}
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -706,4 +852,305 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Tagline panel ──────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'tagline'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="t-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="t-slug" type="text" bind:value={tAC.inputVal} oninput={onTSlugInput}
|
||||
onfocus={() => (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}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each tAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={tSlug === b.slug} onmousedown={() => selectTBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateTagline} disabled={!tCanGenerate}
|
||||
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 tGenerating}
|
||||
<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 tagline{/if}
|
||||
</button>
|
||||
{#if tError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{tError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if tResult}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-brand)/40 rounded-xl p-4">
|
||||
<p class="text-base italic text-(--color-text)">{tResult}</p>
|
||||
</div>
|
||||
<button onclick={() => { tResult = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if tGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin 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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'genres'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="g-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="g-slug" type="text" bind:value={gAC.inputVal} oninput={onGSlugInput}
|
||||
onfocus={() => (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}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each gAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={gSlug === b.slug} onmousedown={() => selectGBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateGenres} disabled={!gCanGenerate}
|
||||
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 gGenerating}
|
||||
<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}Suggest genres{/if}
|
||||
</button>
|
||||
{#if gError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gError}</p>{/if}
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{#if gProposed.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#if gCurrent.length > 0}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">Current genres</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gCurrent as g}
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">
|
||||
Proposed{#if gUsedModel}<span class="normal-case font-normal"> · {gUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gEdited as g, i}
|
||||
<input type="text" bind:value={gEdited[i]}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||||
{/each}
|
||||
<button onclick={() => { gEdited = [...gEdited, '']; }}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors">+ add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick={applyGenres} disabled={!gCanApply}
|
||||
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">
|
||||
{gApplying ? 'Saving…' : gApplySuccess ? 'Saved ✓' : 'Apply genres'}
|
||||
</button>
|
||||
<button onclick={() => { gProposed = []; gEdited = []; gApplySuccess = 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 gApplyError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gApplyError}</p>{/if}
|
||||
{#if gApplySuccess}<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Genres saved successfully.</p>{/if}
|
||||
</div>
|
||||
{:else if gGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-32">
|
||||
<svg class="w-6 h-6 animate-spin 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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-32">
|
||||
<p class="text-sm text-(--color-muted)">Genre suggestions will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Content warnings panel ─────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'warnings'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="w-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="w-slug" type="text" bind:value={wAC.inputVal} oninput={onWSlugInput}
|
||||
onfocus={() => (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}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each wAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={wSlug === b.slug} onmousedown={() => selectWBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateWarnings} disabled={!wCanGenerate}
|
||||
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 wGenerating}
|
||||
<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>Detecting…
|
||||
{:else}Detect content warnings{/if}
|
||||
</button>
|
||||
{#if wError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{wError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if wWarnings.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each wWarnings as w}
|
||||
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={() => { wWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if wWarnings.length === 0 && wUsedModel}
|
||||
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
|
||||
<p class="text-sm text-green-400">No content warnings detected.</p>
|
||||
</div>
|
||||
{:else if wGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin 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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'quality'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="q-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="q-slug" type="text" bind:value={qAC.inputVal} oninput={onQSlugInput}
|
||||
onfocus={() => (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}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each qAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={qSlug === b.slug} onmousedown={() => selectQBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateQualityScore} disabled={!qCanGenerate}
|
||||
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 qGenerating}
|
||||
<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>Scoring…
|
||||
{:else}Score description quality{/if}
|
||||
</button>
|
||||
{#if qError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{qError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if qScore > 0}
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Quality score{#if qUsedModel}<span class="normal-case font-normal"> · {qUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-(--color-brand)">{qScore}</span>
|
||||
<div class="flex gap-1">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<span class="text-xl {star <= qScore ? 'text-amber-400' : 'text-(--color-border)'}">★</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if qFeedback}
|
||||
<p class="text-sm text-(--color-muted)">{qFeedback}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={() => { qScore = 0; qFeedback = ''; qUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if qGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin 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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Quality score will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
43
ui/src/routes/api/admin/catalogue/batch-covers/+server.ts
Normal file
43
ui/src/routes/api/admin/catalogue/batch-covers/+server.ts
Normal file
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/content-warnings/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/content-warnings/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/genres/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/genres/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/genres/apply/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/genres/apply/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/quality-score/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/quality-score/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/tagline/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/tagline/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -1031,7 +1031,7 @@
|
||||
<textarea
|
||||
bind:value={chapterCoverPrompt}
|
||||
rows="2"
|
||||
placeholder="Chapter {chapterCoverN} illustration for "{data.book?.title}". Dramatic scene, vivid colors…"
|
||||
placeholder="Chapter {chapterCoverN} illustration for "{data.book?.title}". Dramatic scene, vivid colors…"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
<div class="flex items-end gap-3 flex-wrap">
|
||||
@@ -1133,7 +1133,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={chapNamesPattern}
|
||||
placeholder="Chapter {n}: {scene}"
|
||||
placeholder="Chapter {'{n}'}: {'{scene}'}"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
|
||||
Reference in New Issue
Block a user