Compare commits

..

2 Commits

Author SHA1 Message Date
root
e399b1ce01 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
2026-04-08 18:30:35 +05:00
root
320f9fc76b feat: add page fade transition and CSS performance improvements
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m35s
Release / Docker (push) Successful in 5m53s
Release / Gitea Release (push) Successful in 29s
- Wrap {#key} children in fade transition (out 100ms, in 180ms+60ms delay)
  for a smooth cross-fade between pages with no added dependencies
- Halve navigation progress bar animation duration (8s → 4s) for
  more realistic feedback on typical navigations
- Add prefers-reduced-motion media query to collapse all animation/transition
  durations for users with accessibility needs
- Add content-visibility: auto on footer to skip browser paint of
  off-screen content and improve rendering performance
2026-04-08 16:54:51 +05:00
35 changed files with 2134 additions and 548 deletions

View File

@@ -109,6 +109,7 @@ doppler run --project libnovel --config prd_homelab -- docker compose -f homelab
- Prod runner has `profiles: [runner]``docker compose up -d` will NOT accidentally start it
- When deploying, always sync `docker-compose.yml` to the server before running `up -d`
- **Caddyfile is NOT in git** — lives at `/opt/libnovel/Caddyfile` on prod server only. Edit directly on the server and restart the `caddy` container.
---
@@ -117,11 +118,43 @@ doppler run --project libnovel --config prd_homelab -- docker compose -f homelab
| Tool | Purpose |
|---|---|
| GlitchTip | Error tracking (UI + backend + runner) |
| Grafana Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) |
| OpenTelemetry | Distributed tracing (OTLP → collector → Tempo) |
| Grafana | Dashboards at `/admin/grafana` |
| Grafana Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) → Alloy (port 12347) |
| OpenTelemetry | Distributed tracing (OTLP → cloudflared → OTel collector → Tempo) |
| Grafana | Dashboards at `https://grafana.libnovel.cc` |
Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/`
### Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/`
Key dashboards:
- `backend.json` — Backend logs (Loki: `{service_name="backend"}`, plain text)
- `runner.json` — Runner logs (Loki: `{service_name="runner"}`) + Asynq Prometheus metrics
- `web-vitals.json` — Web Vitals (Loki: `{service_name="unknown_service"} kind=measurement` + pattern parser)
- `catalogue.json` — Scrape progress (Loki: `{service_name="runner"} | json | body="..."`)
### Data pipeline (2026-04-07 working state)
**Browser → Grafana Faro:**
Browser sends RUM data → `https://faro.libnovel.cc/collect`**Alloy** `faro.receiver` (port 12347) → Loki (logs/exceptions) + OTel collector → **Tempo** (traces)
**Backend/Runner → OTel:**
Backend/Runner Go SDK → `https://otel.libnovel.cc` (cloudflared tunnel) → **OTel collector** (port 4318) → Tempo (traces) + Loki (logs via `otlphttp/loki` exporter)
Runner also sends to **Alloy** `otelcol.receiver.otlp` (port 4318) → `otelcol.exporter.loki` → Loki
### Loki log format per service
- `service_name="backend"`: Plain text (e.g. `backend: asynq task dispatch enabled`)
- `service_name="runner"`: JSON with `body`, `attributes{slug,chapters,page}`, `severity`
- `service_name="unknown_service"`: Faro RUM text format (e.g. `kind=measurement lcp=5428.0 ...`)
### OTel Collector ports (homelab)
- gRPC: `4317` — receives from cloudflared (`otel.libnovel.cc`)
- HTTP: `4318` — receives from cloudflared + Alloy
- Metrics: `8888`
### Known issues / pending fixes
- Web Vitals use `service_name="unknown_service"` (Faro SDK doesn't set service.name in browser) — works with `unknown_service` label
- Runner logs go to both Alloy→Loki AND OTel collector→Loki (dual pipeline — intentional for resilience)
---

View File

@@ -179,24 +179,25 @@ func run() error {
Commit: commit,
},
backend.Dependencies{
BookReader: store,
RankingStore: store,
AudioStore: store,
TranslationStore: store,
PresignStore: store,
ProgressStore: store,
CoverStore: store,
Producer: producer,
TaskReader: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
Log: log,
BookReader: store,
RankingStore: store,
AudioStore: store,
TranslationStore: store,
PresignStore: store,
ProgressStore: store,
CoverStore: store,
ChapterImageStore: store,
Producer: producer,
TaskReader: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
Log: log,
},
)

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)

View File

@@ -171,6 +171,20 @@ type AIJobStore interface {
ListAIJobs(ctx context.Context) ([]domain.AIJob, error)
}
// ChapterImageStore covers per-chapter illustration images stored in MinIO.
// The backend admin writes them; the backend serves them.
type ChapterImageStore interface {
// PutChapterImage stores a raw image for chapter n of slug in MinIO.
PutChapterImage(ctx context.Context, slug string, n int, data []byte, contentType string) error
// GetChapterImage retrieves the image for chapter n of slug.
// Returns (nil, "", false, nil) when no image exists.
GetChapterImage(ctx context.Context, slug string, n int) ([]byte, string, bool, error)
// ChapterImageExists returns true when an image is stored for slug/n.
ChapterImageExists(ctx context.Context, slug string, n int) bool
}
// TranslationStore covers machine-translated chapter storage in MinIO.
// The runner writes translations; the backend reads them.
type TranslationStore interface {

View File

@@ -134,6 +134,12 @@ func CoverObjectKey(slug string) string {
return fmt.Sprintf("covers/%s.jpg", slug)
}
// ChapterImageObjectKey returns the MinIO object key for a chapter illustration.
// Format: chapter-images/{slug}/{n:06d}.jpg
func ChapterImageObjectKey(slug string, n int) string {
return fmt.Sprintf("chapter-images/%s/%06d.jpg", slug, n)
}
// TranslationObjectKey returns the MinIO object key for a translated chapter.
// Format: {lang}/{slug}/{n:06d}.md
func TranslationObjectKey(lang, slug string, n int) string {
@@ -265,3 +271,28 @@ func coverContentType(data []byte) string {
}
return "image/jpeg"
}
// ── Chapter image operations ───────────────────────────────────────────────────
// putChapterImage stores a chapter illustration in the browse bucket.
func (m *minioClient) putChapterImage(ctx context.Context, key, contentType string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, contentType, data)
}
// getChapterImage retrieves a chapter illustration. Returns (nil, false, nil)
// when the object does not exist.
func (m *minioClient) getChapterImage(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
data, err := m.getObject(ctx, m.bucketBrowse, key)
if err != nil {
return nil, false, err
}
return data, true, nil
}
// chapterImageExists returns true when the chapter image object exists.
func (m *minioClient) chapterImageExists(ctx context.Context, key string) bool {
return m.objectExists(ctx, m.bucketBrowse, key)
}

View File

@@ -54,6 +54,7 @@ var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ bookstore.ChapterImageStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -1043,6 +1044,36 @@ func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}
// ── ChapterImageStore ──────────────────────────────────────────────────────────
func (s *Store) PutChapterImage(ctx context.Context, slug string, n int, data []byte, contentType string) error {
key := ChapterImageObjectKey(slug, n)
if contentType == "" {
contentType = coverContentType(data)
}
if err := s.mc.putChapterImage(ctx, key, contentType, data); err != nil {
return fmt.Errorf("PutChapterImage: %w", err)
}
return nil
}
func (s *Store) GetChapterImage(ctx context.Context, slug string, n int) ([]byte, string, bool, error) {
key := ChapterImageObjectKey(slug, n)
data, ok, err := s.mc.getChapterImage(ctx, key)
if err != nil {
return nil, "", false, fmt.Errorf("GetChapterImage: %w", err)
}
if !ok {
return nil, "", false, nil
}
ct := coverContentType(data)
return data, ct, true, nil
}
func (s *Store) ChapterImageExists(ctx context.Context, slug string, n int) bool {
return s.mc.chapterImageExists(ctx, ChapterImageObjectKey(slug, n))
}
// ── TranslationStore ───────────────────────────────────────────────────────────
func (s *Store) TranslationObjectKey(lang, slug string, n int) string {

View File

@@ -84,8 +84,10 @@ services:
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
# OTel — send runner traces/metrics to the local collector (HTTP)
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
# OTel — send runner traces/logs to Alloy (HTTP)
# Alloy forwards traces → OTel collector → Tempo
# logs → Loki
OTEL_EXPORTER_OTLP_ENDPOINT: "http://alloy:4318"
OTEL_SERVICE_NAME: "runner"
healthcheck:
@@ -370,6 +372,7 @@ services:
expose:
- "12347" # Faro HTTP receiver (POST /collect)
- "12348" # Alloy UI / health endpoint
- "4318" # OTLP receiver (HTTP) for backend/runner logs
depends_on:
- otel-collector
- loki
@@ -382,9 +385,9 @@ services:
volumes:
- ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro
expose:
- "4317" # OTLP gRPC
- "4318" # OTLP HTTP
- "8888" # Collector self-metrics (scraped by Prometheus)
- "14317" # OTLP gRPC (Alloy forwards traces here)
- "14318" # OTLP HTTP (Alloy forwards traces here)
- "8888" # Collector self-metrics (scraped by Prometheus)
depends_on:
- tempo
- prometheus

View File

@@ -1,15 +1,19 @@
// Grafana Alloy — Faro RUM receiver
// Grafana Alloy — Faro RUM receiver + OTel log bridge
//
// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the
// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk.
//
// Also receives OTLP logs from the backend and runner services, and forwards
// them to Loki in the native push format (solving the OTLP→Loki gap).
//
// Pipeline:
// faro.receiver → receives HTTP POST /collect from browsers
// faro.receiver → receives HTTP POST /collect from browsers
// otelcol.receiver.otlp → receives OTLP logs from backend/runner (HTTP :4318)
// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo
// loki.write → forwards logs/exceptions to Loki
// loki.write → forwards Faro logs/exceptions to Loki
// otelcol.exporter.loki → forwards OTel logs to Loki (native format)
//
// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared.
// CORS is configured to allow requests from libnovel.cc.
faro.receiver "faro" {
server {
@@ -25,6 +29,40 @@ faro.receiver "faro" {
}
}
// Receive OTLP traces and logs from backend/runner
otelcol.receiver.otlp "otel_logs" {
http {
endpoint = "0.0.0.0:4318"
}
output {
logs = [otelcol.exporter.loki.otel_logs.input]
traces = [otelcol.exporter.otlphttp.otel_logs.input]
}
}
// Convert OTel logs to Loki format and forward to loki.write
otelcol.exporter.loki "otel_logs" {
forward_to = [loki.write.otel_logs.receiver]
}
// Send backend/runner traces to the OTel Collector → Tempo
otelcol.exporter.otlphttp "otel_logs" {
client {
endpoint = "http://otel-collector:4318"
tls {
insecure = true
}
}
}
// Push backend/runner logs to Loki (native push format)
loki.write "otel_logs" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
// Forward Faro traces to the OTel Collector (which routes to Tempo)
otelcol.exporter.otlphttp "faro" {
client {

View File

@@ -17,7 +17,7 @@ processors:
timeout: 5s
send_batch_size: 512
# Attach host metadata to all telemetry
# Attach host metadata to traces/metrics
resourcedetection:
detectors: [env, system]
timeout: 5s
@@ -73,5 +73,7 @@ service:
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [resourcedetection, batch]
# No resourcedetection — preserve service.name from OTel resource attributes
# (backend=backend, runner=runner, Alloy/Faro=no service.name → unknown_service)
processors: [batch]
exporters: [otlphttp/loki]

View File

@@ -298,7 +298,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"backend\"} | json | level =~ `(WARN|ERROR|error|warn)`",
"expr": "{service_name=\"backend\"}",
"legendFormat": ""
}
]

View File

@@ -1,7 +1,7 @@
{
"uid": "libnovel-catalogue",
"title": "Catalogue & Content Progress",
"description": "Scraping progress, audio generation coverage, and catalogue health derived from runner structured logs.",
"description": "Scraping progress from runner OTel logs in Loki. Logs are JSON: body=message, attributes.slug/chapters/page=fields.",
"tags": ["libnovel", "catalogue", "content"],
"timezone": "browser",
"refresh": "1m",
@@ -12,9 +12,9 @@
"id": 1,
"type": "stat",
"title": "Books Scraped (last 24h)",
"description": "Count of unique book slugs appearing in successful scrape task completions.",
"description": "Count of unique slugs from chapter list fetched messages.",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
@@ -24,7 +24,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task done` [24h])",
"expr": "count(count_over_time({service_name=\"runner\"} | json | body=\"chapter list fetched\" [24h])) by (attributes_slug)",
"legendFormat": "books scraped"
}
]
@@ -33,8 +33,9 @@
"id": 2,
"type": "stat",
"title": "Chapters Scraped (last 24h)",
"description": "Count of 'chapter list fetched' events.",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
@@ -44,17 +45,18 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | unwrap scraped [24h])",
"legendFormat": "chapters scraped"
"expr": "sum(count_over_time({service_name=\"runner\"} | json | body=\"chapter list fetched\" [24h]))",
"legendFormat": "chapter lists fetched"
}
]
},
{
"id": 3,
"type": "stat",
"title": "Audio Jobs Completed (last 24h)",
"title": "Metadata Saved (last 24h)",
"description": "Count of 'metadata saved' events.",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "green", "mode": "fixed" },
@@ -64,43 +66,18 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task done` [24h])",
"legendFormat": "audio done"
"expr": "sum(count_over_time({service_name=\"runner\"} | json | body=\"metadata saved\" [24h]))",
"legendFormat": "metadata saved"
}
]
},
{
"id": 4,
"type": "stat",
"title": "Audio Jobs Failed (last 24h)",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task failed` [24h])",
"legendFormat": "audio failed"
}
]
},
{
"id": 5,
"type": "stat",
"title": "Scrape Errors (last 24h)",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" },
"description": "Count of error severity logs from the runner.",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"thresholds": {
@@ -116,97 +93,93 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task failed` [24h])",
"legendFormat": "scrape errors"
"expr": "sum(count_over_time({service_name=\"runner\"} | json | severity=\"ERROR\" [24h]))",
"legendFormat": "errors"
}
]
},
{
"id": 6,
"id": 5,
"type": "stat",
"title": "Catalogue Refresh — Books Indexed",
"description": "Total books indexed in the last catalogue refresh cycle (from the ok field in the summary log).",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"title": "Rate Limited (last 24h)",
"description": "Count of rate limiting events from Novelfire.",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "purple", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 5 },
{ "color": "red", "value": 50 }
]
}
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "last_over_time({service_name=\"runner\"} | json | op=`catalogue_refresh` | msg=`catalogue refresh done` | unwrap ok [7d])",
"legendFormat": "indexed"
"expr": "sum(count_over_time({service_name=\"runner\"} | json | body=~\"rate limit\" [24h]))",
"legendFormat": "rate limited"
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Audio Generation Rate (tasks/min)",
"title": "Scrape Rate (books/min)",
"description": "Rate of events per minute.",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"description": "Rate of audio task completions and failures over time.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "completed"
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum(rate({service_name=\"runner\"} | json | body=\"chapter list fetched\" [5m])) * 60",
"legendFormat": "books/min"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "failed"
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum(rate({service_name=\"runner\"} | json | body=\"metadata saved\" [5m])) * 60",
"legendFormat": "metadata/min"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "Scraping Rate (tasks/min)",
"title": "Error Rate (errors/min)",
"description": "Rate of error and rate-limit messages over time.",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"description": "Rate of scrape task completions and failures over time.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }
{ "matcher": { "id": "byName", "options": "errors/min" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "rate-limit/min" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "completed"
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum(rate({service_name=\"runner\"} | json | severity=\"ERROR\" [5m])) * 60",
"legendFormat": "errors/min"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "failed"
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum(rate({service_name=\"runner\"} | json | body=~\"rate limit\" [5m])) * 60",
"legendFormat": "rate-limit/min"
}
]
},
{
"id": 20,
"type": "logs",
"title": "Scrape Task Events",
"description": "One log line per completed or failed scrape task. Fields: task_id, kind, url, scraped, skipped, errors.",
"title": "Runner Logs (errors & warnings)",
"description": "Runner log lines containing errors or warnings.",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
"options": {
"showTime": true,
@@ -220,7 +193,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | msg =~ `scrape task (done|failed|starting)`",
"expr": "{service_name=\"runner\"} | json | severity=~\"ERROR|WARN\"",
"legendFormat": ""
}
]
@@ -228,12 +201,12 @@
{
"id": 21,
"type": "logs",
"title": "Audio Task Events",
"description": "One log line per completed or failed audio task. Fields: task_id, slug, chapter, voice, key (on success), reason (on failure).",
"title": "Runner Logs (all)",
"description": "All runner log entries.",
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"showLabels": true,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
@@ -243,30 +216,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | msg =~ `audio task (done|failed|starting)`",
"legendFormat": ""
}
]
},
{
"id": 22,
"type": "logs",
"title": "Catalogue Refresh Progress",
"description": "Progress logs from the background catalogue refresh (every 24h). Fields: op=catalogue_refresh, scraped, ok, skipped, errors.",
"gridPos": { "x": 0, "y": 32, "w": 24, "h": 8 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | op=`catalogue_refresh`",
"expr": "{service_name=\"runner\"}",
"legendFormat": ""
}
]

View File

@@ -345,7 +345,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | level =~ `(WARN|ERROR|error|warn)`",
"expr": "{service_name=\"runner\"}",
"legendFormat": ""
}
]
@@ -368,7 +368,7 @@
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json",
"expr": "{service_name=\"runner\"}",
"legendFormat": ""
}
]

View File

@@ -1,37 +1,71 @@
{
"uid": "libnovel-web-vitals",
"title": "Web Vitals (RUM)",
"description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser Alloy faro.receiver → Tempo (traces) + Loki (logs).",
"tags": ["libnovel", "frontend", "rum", "web-vitals"],
"description": "Core Web Vitals from @grafana/faro-web-sdk. Data: browser \u2192 Alloy faro.receiver \u2192 Loki ({service_name=unknown_service}). Log format: key=value pairs, e.g. lcp=767.000000 fcp=767.000000. Use | regexp to extract.",
"tags": [
"libnovel",
"frontend",
"rum",
"web-vitals"
],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"time": {
"from": "now-24h",
"to": "now"
},
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "LCP p75 (Largest Contentful Paint)",
"description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"title": "LCP \u2014 p75 (Largest Contentful Paint)",
"description": "Good < 2.5s, needs improvement < 4s, poor >= 4s. Source: Loki {service_name=unknown_service} Faro measurements.",
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 2500 },
{ "color": "red", "value": 4000 }
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 2500
},
{
"color": "red",
"value": 4000
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P<lcp>\\d+\\.?\\d*)` | unwrap lcp [1h])",
"legendFormat": "LCP p75",
"instant": true
}
@@ -40,27 +74,53 @@
{
"id": 2,
"type": "stat",
"title": "INP p75 (Interaction to Next Paint)",
"description": "Good < 200 ms, needs improvement < 500 ms, poor 500 ms.",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"title": "INP \u2014 p75 (Interaction to Next Paint)",
"description": "Good < 200ms, needs improvement < 500ms, poor >= 500ms.",
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 500 }
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 200
},
{
"color": "red",
"value": 500
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `inp=(?P<inp>\\d+\\.?\\d*)` | unwrap inp [1h])",
"legendFormat": "INP p75",
"instant": true
}
@@ -69,10 +129,23 @@
{
"id": 3,
"type": "stat",
"title": "CLS p75 (Cumulative Layout Shift)",
"description": "Good < 0.1, needs improvement < 0.25, poor 0.25.",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"title": "CLS \u2014 p75 (Cumulative Layout Shift)",
"description": "Good < 0.1, needs improvement < 0.25, poor >= 0.25.",
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "short",
@@ -80,17 +153,29 @@
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 0.1
},
{
"color": "red",
"value": 0.25
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `cls=(?P<cls>\\d+\\.?\\d*)` | unwrap cls [1h])",
"legendFormat": "CLS p75",
"instant": true
}
@@ -99,27 +184,53 @@
{
"id": 4,
"type": "stat",
"title": "TTFB p75 (Time to First Byte)",
"description": "Good < 800 ms, needs improvement < 1800 ms, poor 1800 ms.",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"title": "TTFB \u2014 p75 (Time to First Byte)",
"description": "Good < 800ms, needs improvement < 1800ms, poor >= 1800ms.",
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 800 },
{ "color": "red", "value": 1800 }
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 800
},
{
"color": "red",
"value": 1800
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P<ttfb>\\d+\\.?\\d*)` | unwrap ttfb [1h])",
"legendFormat": "TTFB p75",
"instant": true
}
@@ -128,27 +239,53 @@
{
"id": 5,
"type": "stat",
"title": "FCP p75 (First Contentful Paint)",
"description": "Good < 1.8 s, needs improvement < 3 s, poor 3 s.",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"title": "FCP \u2014 p75 (First Contentful Paint)",
"description": "Good < 1.8s, needs improvement < 3s, poor >= 3s.",
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1800 },
{ "color": "red", "value": 3000 }
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 1800
},
{
"color": "red",
"value": 3000
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `fcp=(?P<fcp>\\d+\\.?\\d*)` | unwrap fcp [1h])",
"legendFormat": "FCP p75",
"instant": true
}
@@ -157,20 +294,45 @@
{
"id": 6,
"type": "stat",
"title": "Active Sessions (30 min)",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"title": "Measurements / min",
"description": "Number of Faro measurement events in the last 5 minutes (activity indicator).",
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))",
"legendFormat": "sessions",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "sum(count_over_time({service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" [5m]))",
"legendFormat": "measurements",
"instant": true
}
]
@@ -179,58 +341,176 @@
"id": 10,
"type": "timeseries",
"title": "LCP over time (p50 / p75 / p95)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 8
},
"options": {
"tooltip": {
"mode": "multi"
},
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"defaults": {
"unit": "ms",
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
},
"overrides": [
{ "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] },
{ "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }
{
"matcher": {
"id": "byName",
"options": "Good (2.5s)"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "green",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"fill": "dash",
"dash": [
4,
4
]
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Poor (4s)"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"fill": "dash",
"dash": [
4,
4
]
}
}
]
}
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P<lcp>\\d+\\.?\\d*)` | unwrap lcp [5m])",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P<lcp>\\d+\\.?\\d*)` | unwrap lcp [5m])",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P<lcp>\\d+\\.?\\d*)` | unwrap lcp [5m])",
"legendFormat": "p95"
},
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" }
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "2500",
"legendFormat": "Good (2.5s)"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "4000",
"legendFormat": "Poor (4s)"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "TTFB over time (p50 / p75 / p95)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 8
},
"options": {
"tooltip": {
"mode": "multi"
},
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
"defaults": {
"unit": "ms",
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P<ttfb>\\d+\\.?\\d*)` | unwrap ttfb [5m])",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P<ttfb>\\d+\\.?\\d*)` | unwrap ttfb [5m])",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P<ttfb>\\d+\\.?\\d*)` | unwrap ttfb [5m])",
"legendFormat": "p95"
}
]
@@ -239,8 +519,13 @@
"id": 20,
"type": "logs",
"title": "Frontend Errors & Exceptions",
"description": "JS exceptions and console errors captured by Faro and shipped to Loki.",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
"description": "JS exceptions captured by Faro. kind=exception events.",
"gridPos": {
"x": 0,
"y": 12,
"w": 24,
"h": 10
},
"options": {
"showTime": true,
"showLabels": true,
@@ -252,8 +537,11 @@
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "{service_name=\"unknown_service\"} | regexp `(?P<kind>\\w+)` | kind = \"exception\"",
"legendFormat": ""
}
]
@@ -261,24 +549,256 @@
{
"id": 21,
"type": "logs",
"title": "Frontend Logs (all Faro events)",
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
"title": "Web Vitals Measurements",
"description": "All Faro measurement events.",
"gridPos": {
"x": 0,
"y": 22,
"w": 24,
"h": 10
},
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true,
"showLabels": true,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "{service_name=\"unknown_service\"} | regexp `(?P<kind>\\w+)` | kind = \"measurement\"",
"legendFormat": ""
}
]
},
{
"id": 30,
"type": "row",
"title": "API Performance (Upstream Requests)",
"gridPos": { "x": 0, "y": 32, "w": 24, "h": 1 },
"collapsed": false
},
{
"id": 31,
"type": "timeseries",
"title": "API Request Duration — p50 / p75 / p95 by endpoint",
"description": "Duration of all libnovel.cc/api/* fetch requests captured by Faro faro.performance.resource events. Values in ms.",
"gridPos": { "x": 0, "y": 33, "w": 24, "h": 10 },
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max", "lastNotNull"] }
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"custom": { "lineWidth": 2, "fillOpacity": 5 }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"}",
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/progress/audio-time"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/progress/audio-time"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/presign/audio"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/presign/audio"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/progress"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/progress"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/comments"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/comments"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/settings"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/settings"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p50 /api/catalogue-page"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [5m])",
"legendFormat": "p95 /api/catalogue-page"
}
]
},
{
"id": 32,
"type": "barchart",
"title": "API Avg Duration — last 1h",
"description": "Average duration per endpoint over the last hour. Useful for spotting the slowest APIs at a glance.",
"gridPos": { "x": 0, "y": 43, "w": 12, "h": 8 },
"options": {
"orientation": "horizontal",
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "single" },
"xTickLabelRotation": 0
},
"fieldConfig": {
"defaults": { "unit": "ms", "color": { "mode": "palette-classic" } }
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/progress/audio-time",
"instant": true
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/presign/audio",
"instant": true
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/progress",
"instant": true
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/comments",
"instant": true
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/settings",
"instant": true
},
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h])",
"legendFormat": "/api/catalogue-page",
"instant": true
}
]
},
{
"id": 33,
"type": "stat",
"title": "Slowest API call — p95 last 1h",
"description": "p95 duration of the single slowest endpoint in the last hour.",
"gridPos": { "x": 12, "y": 43, "w": 6, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 500 },
{ "color": "red", "value": 1000 }
]
}
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "max(quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur [1h]))",
"legendFormat": "p95 max",
"instant": true
}
]
},
{
"id": 34,
"type": "stat",
"title": "API Requests / min",
"description": "Rate of libnovel.cc API requests captured by Faro in the last 5 minutes.",
"gridPos": { "x": 18, "y": 43, "w": 6, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum(count_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" [5m])) / 5",
"legendFormat": "req/min",
"instant": true
}
]
},
{
"id": 35,
"type": "logs",
"title": "Slow API Requests (>500ms)",
"description": "Individual faro.performance.resource events where duration > 500ms. Useful for debugging outliers.",
"gridPos": { "x": 0, "y": 47, "w": 24, "h": 8 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": false,
"prettifyLogMessage": false,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" | regexp `event_data_duration=(?P<dur>[0-9.]+)` | unwrap dur | dur > 500",
"legendFormat": ""
}
]
}
]
}
}

View File

@@ -478,5 +478,20 @@
"feed_reader_label": "reading",
"feed_chapters_label": "{n} chapters",
"feed_browse_cta": "Browse catalogue",
"feed_find_users_cta": "Discover readers"
"feed_find_users_cta": "Discover readers",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -476,5 +476,20 @@
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -476,5 +476,20 @@
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -476,5 +476,20 @@
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -476,5 +476,20 @@
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -247,5 +247,22 @@ html {
100% { width: 100%; opacity: 0; }
}
.animate-progress-bar {
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}
/* ── Respect reduced motion — disable all decorative animations ─────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ── Footer content-visibility — skip paint for off-screen footer ───── */
footer {
content-visibility: auto;
contain-intrinsic-size: auto 80px;
}

View File

@@ -646,6 +646,7 @@ export interface UserLibraryEntry {
user_id?: string;
slug: string;
saved_at: string;
shelf?: string;
}
function libraryFilter(sessionId: string, userId?: string): string {
@@ -675,6 +676,19 @@ export async function isBookSaved(
return row !== null;
}
/** Returns the shelf the user has placed this book on, or '' if not saved / no shelf set. */
export async function getBookShelf(
sessionId: string,
slug: string,
userId?: string
): Promise<ShelfName> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const row = await listOne<UserLibraryEntry>('user_library', filter).catch(() => null);
return (row?.shelf as ShelfName) || '';
}
/** Save a book to the user's library. No-op if already saved. */
export async function saveBook(
sessionId: string,

View File

@@ -13,7 +13,7 @@
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
import { fly } from 'svelte/transition';
import { fly, fade } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -823,7 +823,9 @@
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
{@render children()}
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
{@render children()}
</div>
{/key}
</main>

View File

@@ -3,25 +3,89 @@
import * as m from '$lib/paraglide/messages.js';
const internalLinks = [
{ href: '/admin/scrape', label: () => m.admin_nav_scrape() },
{ href: '/admin/audio', label: () => m.admin_nav_audio() },
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() },
{ href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() }
{
href: '/admin/scrape',
label: () => m.admin_nav_scrape(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />`
},
{
href: '/admin/audio',
label: () => m.admin_nav_audio(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
},
{
href: '/admin/translation',
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
},
{
href: '/admin/text-gen',
label: () => m.admin_nav_text_gen(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />`
},
{
href: '/admin/ai-jobs',
label: () => m.admin_nav_ai_jobs(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
},
{
href: '/admin/catalogue-tools',
label: () => m.admin_nav_catalogue_tools(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />`
},
{
href: '/admin/changelog',
label: () => m.admin_nav_changelog(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
}
];
const externalLinks = [
{ href: 'https://feedback.libnovel.cc', label: () => m.admin_nav_feedback() },
{ href: 'https://errors.libnovel.cc', label: () => m.admin_nav_errors() },
{ href: 'https://analytics.libnovel.cc', label: () => m.admin_nav_analytics() },
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
{ href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() },
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
{
href: 'https://feedback.libnovel.cc',
label: () => m.admin_nav_feedback(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />`
},
{
href: 'https://errors.libnovel.cc',
label: () => m.admin_nav_errors(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`
},
{
href: 'https://analytics.libnovel.cc',
label: () => m.admin_nav_analytics(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`
},
{
href: 'https://logs.libnovel.cc',
label: () => m.admin_nav_logs(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10" />`
},
{
href: 'https://grafana.libnovel.cc',
label: () => m.admin_nav_grafana(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
},
{
href: 'https://uptime.libnovel.cc',
label: () => m.admin_nav_uptime(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />`
},
{
href: 'https://push.libnovel.cc',
label: () => m.admin_nav_push(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
},
{
href: 'https://gitea.kalekber.cc/kamil/libnovel',
label: () => m.admin_nav_gitea(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />`
}
];
interface Props {
@@ -45,44 +109,53 @@
<!-- Sidebar -->
<aside
class="
fixed top-0 left-0 h-full z-50 w-56 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6
fixed top-0 left-0 h-full z-50 w-56 shrink-0 border-r border-(--color-border) px-2 py-5 flex flex-col gap-5
bg-(--color-surface) transition-transform duration-200
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
md:relative md:translate-x-0 md:w-48 md:z-auto md:top-auto md:h-auto
md:relative md:translate-x-0 md:w-52 md:z-auto md:top-auto md:h-auto
"
>
<!-- Internal pages -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
<p class="px-3 mb-1.5 text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
{@const active = page.url.pathname.startsWith(link.href)}
<a
href={link.href}
onclick={() => (sidebarOpen = false)}
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
{page.url.pathname.startsWith(link.href)
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-2.5
{active
? 'bg-(--color-surface-2) text-(--color-text)'
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
>
{link.label()}
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html link.icon}
</svg>
{link.label()}
</a>
{/each}
</nav>
</div>
<div class="border-t border-(--color-border)"></div>
<!-- External tools -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
<p class="px-3 mb-1.5 text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
<nav class="flex flex-col gap-0.5">
{#each externalLinks as link}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
class="px-3 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center gap-2.5"
>
{link.label()}
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5 shrink-0 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html link.icon}
</svg>
<span class="flex-1">{link.label()}</span>
<svg class="w-2.5 h-2.5 shrink-0 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>

View File

@@ -4,6 +4,7 @@
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -55,25 +56,25 @@
});
// ── Cancel ────────────────────────────────────────────────────────────────────
let cancellingId = $state<string | null>(null);
let cancelError = $state('');
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
async function cancelJob(id: string) {
if (cancellingId) return;
cancellingId = id;
cancelError = '';
if (cancellingIds.has(id)) return;
cancellingIds = new Set([...cancellingIds, id]);
delete cancelErrors[id];
try {
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelError = body.error ?? `Error ${res.status}`;
cancelErrors = { ...cancelErrors, [id]: body.error ?? `Error ${res.status}` };
} else {
await invalidateAll();
jobs = jobs.map((j) => j.id === id ? { ...j, status: 'cancelled' as AIJob['status'] } : j);
}
} catch {
cancelError = 'Network error.';
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
} finally {
cancellingId = null;
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
}
}
@@ -105,6 +106,7 @@
jobId: string;
slug: string;
imageType: string;
chapter: number;
prompt: string;
imageSrc: string;
contentType: string;
@@ -175,6 +177,7 @@
jobId: job.id,
slug: job.slug,
imageType: '',
chapter: 0,
prompt: '',
imageSrc: '',
contentType: 'image/png',
@@ -195,6 +198,7 @@
let payload: {
prompt?: string;
type?: string;
chapter?: number;
content_type?: string;
image_b64?: string;
bytes?: number;
@@ -207,6 +211,7 @@
return;
}
r.imageType = payload.type ?? 'cover';
r.chapter = payload.chapter ?? 0;
r.prompt = payload.prompt ?? '';
r.contentType = payload.content_type ?? 'image/png';
r.bytes = payload.bytes ?? 0;
@@ -314,6 +319,33 @@
}
}
// ── Save image as chapter illustration ───────────────────────────────────────
async function saveImageAsChapterImage() {
if (review?.kind !== 'image-gen' || review.saving) return;
review.saving = true;
review.saveError = '';
const b64 = review.imageSrc.split(',')[1];
try {
const res = await fetch('/api/admin/image-gen/save-chapter-image', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: review.slug, chapter: review.chapter, image_b64: b64 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
review.saveError = body.error ?? `Error ${res.status}`;
} else {
review.savedUrl = body.image_url ?? `/api/chapter-image/novelfire.net/${review.slug}/${review.chapter}`;
}
} catch {
review.saveError = 'Network error.';
} finally {
review.saving = false;
}
}
function downloadImage() {
if (review?.kind !== 'image-gen') return;
const a = document.createElement('a');
@@ -406,12 +438,16 @@
const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
</script>
<svelte:head>
<title>{m.admin_ai_jobs_page_title()}</title>
</svelte:head>
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_ai_jobs_heading()}</h1>
<p class="text-sm text-(--color-muted) mt-0.5">{m.admin_ai_jobs_subheading()}</p>
</div>
<button
onclick={() => invalidateAll()}
@@ -466,11 +502,6 @@
</div>
</div>
<!-- Cancel error -->
{#if cancelError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
{/if}
<!-- Jobs table -->
{#if filteredJobs.length === 0}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
@@ -479,7 +510,8 @@
</p>
</div>
{:else}
<div class="rounded-lg border border-(--color-border) overflow-hidden">
<!-- Desktop table -->
<div class="hidden sm:block rounded-lg border border-(--color-border) overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
@@ -508,19 +540,26 @@
{kindLabel(job.kind)}
</td>
<!-- Slug -->
<td class="px-4 py-3 max-w-[12rem]">
{#if job.slug}
<!-- Slug -->
<td class="px-4 py-3 max-w-[12rem]">
{#if job.slug}
<div class="flex flex-col gap-0.5">
<a
href="/admin/image-gen?slug={job.slug}"
href="/books/{job.slug}"
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
>
{job.slug}
</a>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
{#if job.kind === 'image-gen'}
<a href="/admin/image-gen?slug={job.slug}" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ image editor</a>
{:else if job.kind === 'chapter-names' || job.kind === 'description'}
<a href="/admin/text-gen" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ text editor</a>
{/if}
</div>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
<!-- Model -->
<td class="px-4 py-3 hidden sm:table-cell">
@@ -569,13 +608,13 @@
<!-- Actions -->
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
{#if job.status === 'pending' || job.status === 'running'}
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingId === job.id}
disabled={cancellingIds.has(job.id)}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
{cancellingIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
@@ -594,6 +633,11 @@
{job.error_message}
</span>
{/if}
{#if cancelErrors[job.id]}
<span class="text-xs text-(--color-danger)" title={cancelErrors[job.id]}>
{cancelErrors[job.id]}
</span>
{/if}
</div>
</td>
</tr>
@@ -601,6 +645,74 @@
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job (job.id)}
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-3">
<!-- Row 1: status + kind -->
<div class="flex items-center justify-between gap-2">
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
{job.status}
</span>
<span class="text-sm font-medium text-(--color-text)">{kindLabel(job.kind)}</span>
</div>
<!-- Slug + links -->
{#if job.slug}
<div class="flex flex-col gap-0.5">
<a href="/books/{job.slug}" class="text-(--color-brand) hover:underline font-mono text-xs truncate">
{job.slug}
</a>
{#if job.kind === 'image-gen'}
<a href="/admin/image-gen?slug={job.slug}" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ image editor</a>
{:else if job.kind === 'chapter-names' || job.kind === 'description'}
<a href="/admin/text-gen" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ text editor</a>
{/if}
</div>
{/if}
<!-- Meta grid -->
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Model</span>
<span class="text-(--color-muted) font-mono text-right truncate" title={job.model}>{job.model || '—'}</span>
{#if job.items_total > 0}
<span class="text-(--color-muted)">Progress</span>
<span class="text-(--color-muted) text-right tabular-nums">{job.items_done}/{job.items_total}</span>
{/if}
<span class="text-(--color-muted)">Started</span>
<span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span>
<span class="text-(--color-muted) text-right tabular-nums">{duration(job.started, job.finished)}</span>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-wrap">
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingIds.has(job.id)}
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
<button
onclick={() => openReview(job)}
class="px-2.5 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
>
Review
</button>
{/if}
</div>
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
{#if cancelErrors[job.id]}
<p class="text-xs text-(--color-danger)">{cancelErrors[job.id]}</p>
{/if}
</div>
{/each}
</div>
<p class="text-xs text-(--color-muted)">
Showing {filteredJobs.length} of {jobs.length} jobs
</p>
@@ -744,45 +856,53 @@
<p class="text-(--color-text)">{fmtBytes(review.bytes)}</p>
</div>
{/if}
{#if review.savedUrl}
<p class="text-xs text-green-400">
Saved as cover
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
</p>
{/if}
{#if review.saveError}
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
{/if}
{#if review.savedUrl}
<p class="text-xs text-green-400">
Saved →
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
</p>
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer -->
{#if !review.loading && !review.error && review.imageSrc}
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
<div class="flex items-center gap-3">
<!-- Footer -->
{#if !review.loading && !review.error && review.imageSrc}
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
<div class="flex items-center gap-3">
<button
onclick={downloadImage}
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
>
Download
</button>
{#if review.savedUrl}
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
{:else if review.imageType === 'cover'}
<button
onclick={downloadImage}
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
onclick={saveImageAsCover}
disabled={review.saving}
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
>
Download
{review.saving ? 'Saving…' : 'Save as cover'}
</button>
{#if review.imageType === 'cover' && !review.savedUrl}
<button
onclick={saveImageAsCover}
disabled={review.saving}
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
>
{review.saving ? 'Saving…' : 'Save as cover'}
</button>
{:else if review.savedUrl}
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
{/if}
</div>
{:else if review.imageType === 'chapter' && review.chapter > 0}
<button
onclick={saveImageAsChapterImage}
disabled={review.saving}
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
>
{review.saving ? 'Saving…' : `Save as chapter ${review.chapter} image`}
</button>
{/if}
{#if review.saveError}
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- ── Description review ──────────────────────────────────────────────── -->
{:else if review.kind === 'description'}

View File

@@ -73,24 +73,81 @@
// ── Audio jobs stats + filter ────────────────────────────────────────────────
let jobsQ = $state('');
let jobsStatusFilter = $state('all');
const JOB_STATUS_OPTIONS = ['all', 'generating', 'pending', 'done', 'failed'] as const;
let filteredJobs = $derived(
jobsQ.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.voice.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
)
: jobs
jobs.filter((j: AudioJob) => {
const qLower = jobsQ.trim().toLowerCase();
const matchesQ =
!qLower ||
j.slug.toLowerCase().includes(qLower) ||
j.voice.toLowerCase().includes(qLower) ||
j.status.toLowerCase().includes(qLower);
const matchesStatus = jobsStatusFilter === 'all' || j.status === jobsStatusFilter;
return matchesQ && matchesStatus;
})
);
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
done: jobs.filter((j: AudioJob) => j.status === 'done').length,
failed: jobs.filter((j: AudioJob) => j.status === 'failed').length,
pending: jobs.filter((j: AudioJob) => j.status === 'pending').length,
generating: jobs.filter((j: AudioJob) => j.status === 'generating').length,
inFlight: jobs.filter((j: AudioJob) => j.status === 'pending' || j.status === 'generating').length
});
// ── Cancel single job ────────────────────────────────────────────────────────
let cancellingJobIds = $state(new Set<string>());
let cancelJobErrors: Record<string, string> = $state({});
async function cancelJob(id: string) {
if (cancellingJobIds.has(id)) return;
cancellingJobIds = new Set([...cancellingJobIds, id]);
delete cancelJobErrors[id];
try {
const res = await fetch(`/api/admin/ai-jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelJobErrors = { ...cancelJobErrors, [id]: body.error ?? `Error ${res.status}` };
} else {
jobs = jobs.map((j: AudioJob) => j.id === id ? { ...j, status: 'cancelled' } : j);
}
} catch {
cancelJobErrors = { ...cancelJobErrors, [id]: 'Network error.' };
} finally {
cancellingJobIds = new Set([...cancellingJobIds].filter((x) => x !== id));
}
}
// ── Retry failed job ─────────────────────────────────────────────────────────
let retryingJobIds = $state(new Set<string>());
let retryJobErrors: Record<string, string> = $state({});
async function retryJob(job: AudioJob) {
if (retryingJobIds.has(job.id)) return;
retryingJobIds = new Set([...retryingJobIds, job.id]);
delete retryJobErrors[job.id];
try {
const res = await fetch('/api/admin/audio/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: job.slug, voice: job.voice, from: job.chapter, to: job.chapter, force: true })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
retryJobErrors = { ...retryJobErrors, [job.id]: body.error ?? `Error ${res.status}` };
} else {
jobs = jobs.map((j: AudioJob) => j.id === job.id ? { ...j, status: 'pending', error_message: '' } : j);
}
} catch {
retryJobErrors = { ...retryJobErrors, [job.id]: 'Network error.' };
} finally {
retryingJobIds = new Set([...retryingJobIds].filter((x) => x !== job.id));
}
}
// ── Audio cache filter ───────────────────────────────────────────────────────
function parseCacheKey(key: string) {
const parts = key.split('/');
@@ -154,12 +211,29 @@
<!-- ── Audio Jobs tab ─────────────────────────────────────────────────────── -->
{#if activeTab === 'jobs'}
<input
type="search"
bind:value={jobsQ}
placeholder={m.admin_audio_filter_jobs()}
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="flex flex-wrap gap-3 items-center">
<input
type="search"
bind:value={jobsQ}
placeholder={m.admin_audio_filter_jobs()}
class="flex-1 min-w-48 max-w-sm 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)"
/>
<!-- Status pills -->
<div class="flex gap-1 flex-wrap">
{#each JOB_STATUS_OPTIONS as s}
{@const count = s === 'all' ? stats.total : (s === 'generating' ? stats.generating : s === 'pending' ? stats.pending : s === 'done' ? stats.done : stats.failed)}
<button
onclick={() => (jobsStatusFilter = s)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize
{jobsStatusFilter === s
? 'bg-(--color-brand) text-black'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
{s}{count > 0 ? ` ${count}` : ''}
</button>
{/each}
</div>
</div>
{#if filteredJobs.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
@@ -169,65 +243,116 @@
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
<td class="px-4 py-3">
{#if job.status === 'pending' || job.status === 'generating'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingJobIds.has(job.id)}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{/each}
</tbody>
{#if job.status === 'failed'}
<button
onclick={() => retryJob(job)}
disabled={retryingJobIds.has(job.id)}
class="px-2 py-1 rounded text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
>
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
</button>
{/if}
{#if cancelJobErrors[job.id]}
<p class="text-xs text-(--color-danger) mt-1">{cancelJobErrors[job.id]}</p>
{/if}
{#if retryJobErrors[job.id]}
<p class="text-xs text-(--color-danger) mt-1">{retryJobErrors[job.id]}</p>
{/if}
</td>
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="8" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div>
{/each}
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
{#if job.status === 'pending' || job.status === 'generating'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingJobIds.has(job.id)}
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel job'}
</button>
{/if}
{#if job.status === 'failed'}
<button
onclick={() => retryJob(job)}
disabled={retryingJobIds.has(job.id)}
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
>
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
</button>
{/if}
{#if cancelJobErrors[job.id]}
<p class="text-xs text-(--color-danger)">{cancelJobErrors[job.id]}</p>
{/if}
{#if retryJobErrors[job.id]}
<p class="text-xs text-(--color-danger)">{retryJobErrors[job.id]}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/if}

View File

@@ -178,17 +178,32 @@
}
}
// ── Stats ────────────────────────────────────────────────────────────────────
let stats = $derived({
total: tasks.length,
running: tasks.filter((t: ScrapingTask) => t.status === 'running').length,
pending: tasks.filter((t: ScrapingTask) => t.status === 'pending').length,
done: tasks.filter((t: ScrapingTask) => t.status === 'done').length,
failed: tasks.filter((t: ScrapingTask) => t.status === 'failed').length,
cancelled: tasks.filter((t: ScrapingTask) => t.status === 'cancelled').length,
});
// ── Table filter ────────────────────────────────────────────────────────────
let q = $state('');
let statusFilter = $state('all');
const STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
let filtered = $derived(
q.trim()
? tasks.filter(
(t: ScrapingTask) =>
t.kind.toLowerCase().includes(q.toLowerCase()) ||
t.status.toLowerCase().includes(q.toLowerCase()) ||
(t.target_url ?? '').toLowerCase().includes(q.toLowerCase())
)
: tasks
tasks.filter((t: ScrapingTask) => {
const qLower = q.trim().toLowerCase();
const matchesQ =
!qLower ||
t.kind.toLowerCase().includes(qLower) ||
t.status.toLowerCase().includes(qLower) ||
(t.target_url ?? '').toLowerCase().includes(qLower);
const matchesStatus = statusFilter === 'all' || t.status === statusFilter;
return matchesQ && matchesStatus;
})
);
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -226,6 +241,15 @@
{ label: 'Isekai', url: 'https://novelfire.net/genre/isekai' },
{ label: 'Martial Arts', url: 'https://novelfire.net/genre/martial-arts' },
];
function statusPillColor(s: string) {
if (s === 'running') return 'text-(--color-brand)';
if (s === 'pending') return 'text-sky-400';
if (s === 'done') return 'text-green-400';
if (s === 'failed') return 'text-(--color-danger)';
if (s === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
</script>
<svelte:head>
@@ -344,6 +368,27 @@
/>
</div>
<!-- Status filter pills -->
<div class="flex gap-1.5 flex-wrap">
{#each STATUS_OPTIONS as s}
{@const count = s === 'all' ? stats.total : stats[s as keyof typeof stats]}
<button
onclick={() => (statusFilter = s)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize flex items-center gap-1.5
{statusFilter === s
? 'bg-(--color-brand) text-black'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
{s}
{#if count > 0 && s !== 'all'}
<span class="tabular-nums {statusFilter === s ? 'text-black/70' : statusPillColor(s)}">{count}</span>
{:else if s === 'all'}
<span class="tabular-nums opacity-60">{count}</span>
{/if}
</button>
{/each}
</div>
{#if filtered.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}

View File

@@ -2,6 +2,7 @@
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { TextModelInfo, BookSummary } from './+page.server';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -345,7 +346,9 @@
let tGenerating = $state(false);
let tError = $state('');
let tResult = $state('');
let tEdited = $state('');
let tUsedModel = $state('');
let tCopied = $state(false);
let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating);
@@ -354,7 +357,7 @@
async function generateTagline() {
if (!tCanGenerate) return;
tGenerating = true; tError = ''; tResult = '';
tGenerating = true; tError = ''; tResult = ''; tEdited = ''; tCopied = false;
try {
const res = await fetch('/api/admin/text-gen/tagline', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -363,10 +366,17 @@
const body = await res.json().catch(() => ({}));
if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; }
tResult = body.new_tagline ?? '';
tEdited = tResult;
tUsedModel = body.model ?? '';
} catch { tError = 'Network error.'; } finally { tGenerating = false; }
}
async function copyTagline() {
await navigator.clipboard.writeText(tEdited || tResult);
tCopied = true;
setTimeout(() => { tCopied = false; }, 2000);
}
// ── Genres state ──────────────────────────────────────────────────────────────
let gAC = makeBookAC();
let gSlug = $state('');
@@ -424,6 +434,7 @@
let wError = $state('');
let wWarnings = $state<string[]>([]);
let wUsedModel = $state('');
let wCopied = $state(false);
let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating);
@@ -432,7 +443,7 @@
async function generateWarnings() {
if (!wCanGenerate) return;
wGenerating = true; wError = ''; wWarnings = [];
wGenerating = true; wError = ''; wWarnings = []; wCopied = false;
try {
const res = await fetch('/api/admin/text-gen/content-warnings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -445,6 +456,12 @@
} catch { wError = 'Network error.'; } finally { wGenerating = false; }
}
async function copyWarnings() {
await navigator.clipboard.writeText(wWarnings.join(', '));
wCopied = true;
setTimeout(() => { wCopied = false; }, 2000);
}
// ── Quality score state ───────────────────────────────────────────────────────
let qAC = makeBookAC();
let qSlug = $state('');
@@ -477,13 +494,13 @@
</script>
<svelte:head>
<title>Text Gen — Admin</title>
<title>{m.admin_text_gen_page_title()}</title>
</svelte:head>
<div class="space-y-6 max-w-5xl">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Text Generation</h1>
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_text_gen_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
Generate chapter titles and book descriptions using Cloudflare Workers AI.
</p>
@@ -920,32 +937,44 @@
</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>
{#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>
<input
type="text"
bind:value={tEdited}
class="w-full bg-(--color-surface) border border-(--color-brand)/40 rounded-xl px-4 py-3 text-base italic text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<p class="text-xs text-(--color-muted)">Note: tagline field not yet in the data model — copy and use manually.</p>
<div class="flex gap-2">
<button
onclick={copyTagline}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors
{tCopied ? 'bg-green-400/10 text-green-400 border border-green-400/30' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{tCopied ? 'Copied ✓' : 'Copy to clipboard'}
</button>
<button onclick={() => { tResult = ''; tEdited = ''; }} 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>
{: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>
{/if}
</div>
{/if}
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
{#if activeTab === 'genres'}
@@ -1077,38 +1106,48 @@
</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>
{#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>
{: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>
<p class="text-xs text-(--color-muted)">Note: content warnings field not yet in the data model — copy and use manually.</p>
<div class="flex gap-2">
<button
onclick={copyWarnings}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors
{wCopied ? 'bg-green-400/10 text-green-400 border border-green-400/30' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{wCopied ? 'Copied ✓' : 'Copy to clipboard'}
</button>
<button onclick={() => { wWarnings = []; wUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
</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>
{: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>
{/if}
</div>
{/if}
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
{#if activeTab === 'quality'}

View File

@@ -3,6 +3,7 @@
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import type { TranslationJob } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -52,6 +53,7 @@
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
@@ -77,33 +79,91 @@
}
let jobsQ = $state('');
let jobsStatusFilter = $state('all');
const JOB_STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
let filteredJobs = $derived(
jobsQ.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.lang.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
)
: jobs
jobs.filter((j: TranslationJob) => {
const qLower = jobsQ.trim().toLowerCase();
const matchesQ =
!qLower ||
j.slug.toLowerCase().includes(qLower) ||
j.lang.toLowerCase().includes(qLower) ||
j.status.toLowerCase().includes(qLower);
const matchesStatus = jobsStatusFilter === 'all' || j.status === jobsStatusFilter;
return matchesQ && matchesStatus;
})
);
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'running').length
done: jobs.filter((j: TranslationJob) => j.status === 'done').length,
failed: jobs.filter((j: TranslationJob) => j.status === 'failed').length,
running: jobs.filter((j: TranslationJob) => j.status === 'running').length,
pending: jobs.filter((j: TranslationJob) => j.status === 'pending').length,
cancelled: jobs.filter((j: TranslationJob) => j.status === 'cancelled').length,
inFlight: jobs.filter((j: TranslationJob) => j.status === 'pending' || j.status === 'running').length
});
// ── Cancel single job ────────────────────────────────────────────────────────
let cancellingJobIds = $state(new Set<string>());
let cancelJobErrors: Record<string, string> = $state({});
async function cancelJob(id: string) {
if (cancellingJobIds.has(id)) return;
cancellingJobIds = new Set([...cancellingJobIds, id]);
delete cancelJobErrors[id];
try {
const res = await fetch(`/api/admin/ai-jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelJobErrors = { ...cancelJobErrors, [id]: body.error ?? `Error ${res.status}` };
} else {
jobs = jobs.map((j: TranslationJob) => j.id === id ? { ...j, status: 'cancelled' } : j);
}
} catch {
cancelJobErrors = { ...cancelJobErrors, [id]: 'Network error.' };
} finally {
cancellingJobIds = new Set([...cancellingJobIds].filter((x) => x !== id));
}
}
// ── Retry failed job ─────────────────────────────────────────────────────────
let retryingJobIds = $state(new Set<string>());
let retryJobErrors: Record<string, string> = $state({});
async function retryJob(job: TranslationJob) {
if (retryingJobIds.has(job.id)) return;
retryingJobIds = new Set([...retryingJobIds, job.id]);
delete retryJobErrors[job.id];
try {
const res = await fetch('/api/admin/translation/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: job.slug, lang: job.lang, from: job.chapter, to: job.chapter })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
retryJobErrors = { ...retryJobErrors, [job.id]: body.error ?? `Error ${res.status}` };
} else {
jobs = jobs.map((j: TranslationJob) => j.id === job.id ? { ...j, status: 'pending', error_message: '' } : j);
}
} catch {
retryJobErrors = { ...retryJobErrors, [job.id]: 'Network error.' };
} finally {
retryingJobIds = new Set([...retryingJobIds].filter((x) => x !== job.id));
}
}
</script>
<svelte:head>
<title>Translation — Admin</title>
<title>{m.admin_translation_page_title()}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Machine Translation</h1>
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_translation_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
@@ -123,14 +183,14 @@
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'enqueue' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Enqueue
{m.admin_translation_tab_enqueue()}
</button>
<button
onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Jobs
{m.admin_translation_tab_jobs()}
{#if stats.inFlight > 0}
<span
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold"
@@ -248,58 +308,101 @@
<!-- ── Jobs tab ───────────────────────────────────────────────────────────── -->
{#if activeTab === 'jobs'}
<input
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, lang, or status…"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="flex flex-wrap gap-3 items-center">
<input
type="search"
bind:value={jobsQ}
placeholder={m.admin_translation_filter_placeholder()}
class="flex-1 min-w-48 max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="flex gap-1 flex-wrap">
{#each JOB_STATUS_OPTIONS as s}
{@const count = s === 'all' ? stats.total : (stats as Record<string, number>)[s] ?? 0}
<button
onclick={() => (jobsStatusFilter = s)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize
{jobsStatusFilter === s
? 'bg-(--color-brand) text-black'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
{s}{count > 0 ? ` ${count}` : ''}
</button>
{/each}
</div>
</div>
{#if filteredJobs.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No translation jobs yet.'}
{jobsQ.trim() ? m.admin_translation_no_matching() : m.admin_translation_no_jobs()}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Lang</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Lang</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
>{job.slug}</a
>
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
>{duration(job.started, job.finished)}</td
>
<td class="px-4 py-3">
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingJobIds.has(job.id)}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if job.status === 'failed'}
<button
onclick={() => retryJob(job)}
disabled={retryingJobIds.has(job.id)}
class="px-2 py-1 rounded text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
>
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
</button>
{/if}
{#if cancelJobErrors[job.id]}
<p class="text-xs text-(--color-danger) mt-1">{cancelJobErrors[job.id]}</p>
{/if}
{#if retryJobErrors[job.id]}
<p class="text-xs text-(--color-danger) mt-1">{retryJobErrors[job.id]}</p>
{/if}
</td>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
>{job.slug}</a
>
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
>{duration(job.started, job.finished)}</td
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
>{job.error_message}</td
>
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
>{job.error_message}</td
>
</tr>
{/if}
{/each}
</tbody>
{/if}
{/each}
</tbody>
</table>
</div>
@@ -332,9 +435,33 @@
class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span
>
</div>
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingJobIds.has(job.id)}
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if job.status === 'failed'}
<button
onclick={() => retryJob(job)}
disabled={retryingJobIds.has(job.id)}
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
>
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
</button>
{/if}
{#if cancelJobErrors[job.id]}
<p class="text-xs text-(--color-danger)">{cancelJobErrors[job.id]}</p>
{/if}
{#if retryJobErrors[job.id]}
<p class="text-xs text-(--color-danger)">{retryJobErrors[job.id]}</p>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
/**
* POST /api/admin/image-gen/save-chapter-image
*
* Admin-only proxy: persists a pre-generated base64 image as a chapter
* illustration in MinIO without re-calling Cloudflare AI.
*
* Body: { slug: string, chapter: number, image_b64: string }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/image-gen/save-chapter-image', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/image-gen/save-chapter-image', '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 });
};

View File

@@ -0,0 +1,34 @@
/**
* POST /api/admin/translation/bulk
*
* Admin-only proxy to the Go backend's translation bulk-enqueue endpoint.
* Body: { slug, lang, from, to }
* Response 200: { enqueued }
*/
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/translation/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('api/admin/translation/bulk', '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 });
};

View File

@@ -0,0 +1,55 @@
/**
* GET /api/chapter-image/[domain]/[slug]/[n]
* HEAD /api/chapter-image/[domain]/[slug]/[n]
*
* Proxies chapter illustration images from the Go backend.
* HEAD returns 200/404 to check existence; GET returns the full image bytes.
* Returns 404 when no image has been saved for this chapter.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
async function proxyChapterImage({ params, method }: { params: { domain: string; slug: string; n: string }; method: string }) {
const { domain, slug, n } = params;
let res: Response;
try {
res = await backendFetch(
`/api/chapter-image/${encodeURIComponent(domain)}/${encodeURIComponent(slug)}/${encodeURIComponent(n)}`,
{ method }
);
} catch {
throw error(502, 'Could not reach backend');
}
if (res.status === 404) {
throw error(404, 'No chapter image found');
}
if (!res.ok) {
throw error(res.status, 'Could not fetch chapter image');
}
if (method === 'HEAD') {
return new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store' }
});
}
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
const data = await res.arrayBuffer();
return new Response(data, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': String(data.byteLength)
}
});
}
export const GET: RequestHandler = ({ params }) => proxyChapterImage({ params, method: 'GET' });
export const HEAD: RequestHandler = ({ params }) => proxyChapterImage({ params, method: 'HEAD' });

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, isBookSaved, getBookShelf, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
@@ -15,15 +15,16 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (book) {
// Book is in the library — normal path
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg, currentShelf;
try {
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg, currentShelf] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id),
countReadersThisWeek(slug),
getBookRating(locals.sessionId, slug, locals.user?.id),
getBookAvgRating(slug)
getBookAvgRating(slug),
getBookShelf(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
@@ -35,6 +36,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
chapters,
inLib: true,
saved,
currentShelf: currentShelf ?? '',
lastChapter: progress?.chapter ?? null,
readersThisWeek,
userRating: userRating ?? 0,

View File

@@ -64,7 +64,8 @@
}
// ── Shelf ─────────────────────────────────────────────────────────────────
let currentShelf = $state<ShelfName>('');
// svelte-ignore state_referenced_locally
let currentShelf = $state<ShelfName>((data.currentShelf as ShelfName) ?? '');
async function setShelf(shelf: ShelfName) {
currentShelf = shelf;
@@ -256,7 +257,8 @@
let chapterCoverN = $state('1');
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'error' | ''>('');
let chapterCoverResult = $state<'saved' | 'error' | ''>('');
let chapterCoverSaving = $state(false);
let chapterCoverPrompt = $state('');
async function generateChapterCover() {
@@ -287,6 +289,33 @@
}
}
async function saveChapterCover() {
const slug = data.book?.slug;
if (chapterCoverSaving || !chapterCoverPreview || !slug) return;
const n = parseInt(chapterCoverN, 10);
if (!n || n < 1) return;
chapterCoverSaving = true;
chapterCoverResult = '';
try {
const b64 = chapterCoverPreview.replace(/^data:[^;]+;base64,/, '');
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, chapter: n, image_b64: b64 })
});
if (res.ok) {
chapterCoverResult = 'saved';
chapterCoverPreview = null;
} else {
chapterCoverResult = 'error';
}
} catch {
chapterCoverResult = 'error';
} finally {
chapterCoverSaving = false;
}
}
// ── Admin: description generation ─────────────────────────────────────────
let descGenerating = $state(false);
let descPreview = $state('');
@@ -1131,12 +1160,24 @@
</button>
{#if chapterCoverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if chapterCoverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}
</div>
{#if chapterCoverPreview}
<div class="flex items-start gap-3 mt-1">
<img src={chapterCoverPreview} alt="Chapter cover preview" class="w-24 rounded border border-(--color-border)" />
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors pt-1">{m.book_detail_admin_discard()}</button>
<div class="flex flex-col gap-2 pt-1">
<button
onclick={saveChapterCover}
disabled={chapterCoverSaving}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapterCoverSaving ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{chapterCoverSaving ? m.book_detail_admin_saving() : 'Save Ch. Cover'}
</button>
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
</div>
{/if}
</div>
@@ -1265,7 +1306,10 @@
<!-- Audio TTS bulk enqueue -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
<a href="/admin/audio" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Monitor jobs ↗</a>
</div>
<div class="flex flex-col gap-3">
<!-- Voice selector -->
<div class="flex flex-col gap-1">

View File

@@ -86,16 +86,19 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
isPreview: true,
lang: '',
translationStatus: 'unavailable' as string,
isPro: locals.isPro
isPro: locals.isPro,
chapterImageUrl: null as string | null
};
}
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
// Fetch book metadata, chapter index, and voice list in parallel
const [book, chapters, voicesRes] = await Promise.all([
// Fetch book metadata, chapter index, voice list, and chapter image check in parallel.
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
const [book, chapters, voicesRes, chapterImageRes] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
backendFetch('/api/voices').catch(() => null)
backendFetch('/api/voices').catch(() => null),
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null)
]);
if (!book) error(404, `Book "${slug}" not found`);
@@ -103,6 +106,12 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`);
// Chapter image URL — only set when a generated image exists for this chapter
const chapterImageUrl =
chapterImageRes?.ok
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
: null;
// Parse voices — fall back to empty list on error
let voices: Voice[] = [];
try {
@@ -124,19 +133,20 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const tData = (await tRes.json()) as { html: string; lang: string };
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
chapter: chapterIdx,
html: tData.html,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false,
lang,
translationStatus: 'done',
isPro: locals.isPro
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
chapter: chapterIdx,
html: tData.html,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false,
lang,
translationStatus: 'done',
isPro: locals.isPro,
chapterImageUrl
};
}
// 404 = not generated yet — fall through to original, UI can trigger generation
@@ -193,6 +203,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
isPreview: false,
lang: useTranslation ? lang : '',
translationStatus,
isPro: locals.isPro
isPro: locals.isPro,
chapterImageUrl
};
};

View File

@@ -553,7 +553,19 @@
<div class="text-(--color-muted) text-center py-16">
<p>{fetchError || m.reader_audio_error()}</p>
</div>
{:else if layout.readMode === 'paginated'}
{:else}
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
{#if data.chapterImageUrl && !layout.focusMode}
<div class="mt-4 mb-6 -mx-4 sm:mx-0 sm:rounded-xl overflow-hidden">
<img
src={data.chapterImageUrl}
alt="Chapter {data.chapter.number} illustration"
class="w-full object-cover max-h-72 sm:max-h-96"
/>
</div>
{/if}
{#if layout.readMode === 'paginated'}
<!-- ── Paginated reader ───────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
@@ -611,6 +623,7 @@
{@html html}
</div>
{/if}
{/if}
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
{#if !layout.focusMode}