feat: admin UX overhaul — status filters, retry/cancel, mobile cards, i18n, shelf pre-populate
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped

- Admin layout: SVG icons, active highlight, divider between nav sections
- Scrape page: status filter pills with counts, text + status combined search
- Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab
- Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels
- AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading
- Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading
- Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server
- pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry
- New API route: POST /api/admin/translation/bulk (proxy for translation retry)
- i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
This commit is contained in:
root
2026-04-08 18:30:35 +05:00
parent 320f9fc76b
commit e399b1ce01
33 changed files with 2112 additions and 545 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -292,6 +293,129 @@ func sniffImageContentType(data []byte) string {
return "image/png"
}
// saveChapterImageRequest is the JSON body for POST /api/admin/image-gen/save-chapter-image.
type saveChapterImageRequest struct {
// Slug is the book slug.
Slug string `json:"slug"`
// Chapter is the 1-based chapter number.
Chapter int `json:"chapter"`
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
ImageB64 string `json:"image_b64"`
}
// handleAdminImageGenSaveChapterImage handles POST /api/admin/image-gen/save-chapter-image.
//
// Accepts a pre-generated image as base64 and stores it as the chapter illustration
// in MinIO, replacing the existing one if present. Does not call Cloudflare AI.
func (s *Server) handleAdminImageGenSaveChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
jsonError(w, http.StatusServiceUnavailable, "chapter image store not configured")
return
}
var req saveChapterImageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0")
return
}
if req.ImageB64 == "" {
jsonError(w, http.StatusBadRequest, "image_b64 is required")
return
}
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
if err != nil {
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
if err != nil {
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
return
}
}
contentType := sniffImageContentType(imgData)
if err := s.deps.ChapterImageStore.PutChapterImage(r.Context(), req.Slug, req.Chapter, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save-chapter-image failed", "slug", req.Slug, "chapter", req.Chapter, "err", err)
jsonError(w, http.StatusInternalServerError, "save chapter image: "+err.Error())
return
}
s.deps.Log.Info("admin: chapter image saved", "slug", req.Slug, "chapter", req.Chapter, "bytes", len(imgData))
writeJSON(w, 0, map[string]any{
"saved": true,
"image_url": fmt.Sprintf("/api/chapter-image/novelfire.net/%s/%d", req.Slug, req.Chapter),
"bytes": len(imgData),
})
}
// handleHeadChapterImage handles HEAD /api/chapter-image/{domain}/{slug}/{n}.
//
// Returns 200 when an image exists for this chapter, 404 otherwise.
// Used by the SSR loader to check existence without downloading the full image.
func (s *Server) handleHeadChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
w.WriteHeader(http.StatusNotFound)
return
}
slug := r.PathValue("slug")
nStr := r.PathValue("n")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
if s.deps.ChapterImageStore.ChapterImageExists(r.Context(), slug, n) {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}
// handleGetChapterImage handles GET /api/chapter-image/{domain}/{slug}/{n}.
//
// Serves the stored chapter illustration directly from MinIO.
// Returns 404 when no image has been saved for this chapter.
func (s *Server) handleGetChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
http.NotFound(w, r)
return
}
slug := r.PathValue("slug")
nStr := r.PathValue("n")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
jsonError(w, http.StatusBadRequest, "invalid chapter number")
return
}
data, contentType, ok, err := s.deps.ChapterImageStore.GetChapterImage(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("chapter-image: get failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "could not retrieve chapter image")
return
}
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// handleAdminImageGenAsync handles POST /api/admin/image-gen/async.
//
// Fire-and-forget variant: validates the request, creates an ai_job record of

View File

@@ -57,6 +57,9 @@ type Dependencies struct {
// CoverStore reads and writes book cover images from MinIO.
// If nil, the cover endpoint falls back to a CDN redirect.
CoverStore bookstore.CoverStore
// ChapterImageStore reads and writes per-chapter illustration images from MinIO.
// If nil, chapter image endpoints return 404/503.
ChapterImageStore bookstore.ChapterImageStore
// Producer creates scrape/audio tasks in PocketBase.
Producer taskqueue.Producer
// TaskReader reads scrape/audio task records from PocketBase.
@@ -205,6 +208,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
// Chapter image serving
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
// Admin text generation endpoints (chapter names + book description)
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)