Compare commits

...

7 Commits

Author SHA1 Message Date
Admin
0f9977744a feat: enforce bearer token auth on all /api/admin/* endpoints
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 2m12s
Release / Docker (push) Successful in 6m30s
Release / Gitea Release (push) Successful in 29s
Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer
token for every admin route. Also fixes PocketBase filter injection in
notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose
to both backend and ui services. Includes CLAUDE.md for AI assistant guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:04:10 +05:00
root
9f1c82fe05 chore: add site_config collection to pb-init-v3.sh 2026-04-14 10:09:59 +05:00
root
419bb7e366 feat: seasonal decoration overlay + logo animation, admin site-theme config page 2026-04-13 21:42:12 +05:00
root
734ba68eed perf: cache translated chapter responses for 1 hour
Translation content is immutable once generated — add Cache-Control: public,
max-age=3600, stale-while-revalidate=86400 to handleTranslationRead so the
browser and any CDN reuse the response without hitting MinIO on repeat views.
Forward the header through the SvelteKit /api/translation/[slug]/[n] proxy
which previously stripped it by constructing a bare Content-Type-only Response.
2026-04-13 21:34:02 +05:00
root
708f8bcd6f fix: ai-jobs page empty list + missing Review button + no results in modal
Three bugs:

1. +page.server.ts returned an unawaited Promise — SvelteKit awaits it on
   the server anyway so data.jobs arrived as a plain AIJob[] on the client,
   not a Promise. The $effect calling .then() on an array silently failed,
   leaving jobs=[] and the table empty. Fixed by awaiting in the load fn.

2. +page.svelte $effect updated to assign data.jobs directly (plain array)
   instead of calling .then() on it.

3. handlers_textgen.go: final UpdateAIJob (payload+status write) used
   r.Context() which is cancelled when the SSE client disconnects. If the
   browser navigated away mid-job, results were silently dropped and the
   payload stayed as the initial {pattern} stub with no results array.
   Fixed by using context.Background() for the final write, matching the
   pattern already used in handlers_image.go.
2026-04-13 21:25:52 +05:00
root
7009b24568 fix: repair notification system (broken type assertion + wrong PocketBase filter quotes)
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m19s
Release / Gitea Release (push) Successful in 26s
- Add bookstore.NotificationStore interface and wire it directly to *storage.Store
  in Dependencies, bypassing the Asynq wrapper that caused Producer.(*storage.Store)
  to fail with a 500 on every notification endpoint when Redis is configured
- Replace s.deps.Producer.(*storage.Store) type assertion in all 5 notification
  handlers with s.deps.NotificationStore (nil-safe, always works)
- Fix PocketBase filter single-quote bug in ListNotifications, ClearAllNotifications,
  and MarkAllNotificationsRead (PocketBase requires double quotes for string values)
2026-04-13 21:18:49 +05:00
root
5b90667b4b fix: replace {#await} IIFE trick with $effect for streamed data on discover page
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m4s
Release / Gitea Release (push) Successful in 28s
The {#await ... then} + {@const} IIFE pattern for assigning to $state
variables stopped working reliably in Svelte 5.53+. Replaced with a
proper $effect that awaits both streamed promises and assigns to state,
which correctly triggers reactivity.

Also: switch library page selection mode entry from long-press to a
'Select' button in the page header.
2026-04-13 21:14:14 +05:00
26 changed files with 938 additions and 125 deletions

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Docker (via `just` — the primary way to run services)
All services use Doppler for secrets injection. The `just` commands handle this automatically.
```bash
just up # Start all services in background
just up-fg # Start all services, stream logs
just down # Stop all services
just down-volumes # Full reset (destructive — removes all volumes)
just build # Rebuild all Docker images
just build-svc backend # Rebuild a specific service
just restart # Stop + rebuild + start
just logs # Tail all logs
just log backend # Tail a specific service
just shell backend # Open shell in running container
just init # One-shot init: MinIO buckets, PocketBase collections, Postgres
```
### Backend (Go)
```bash
cd backend
go vet ./...
go test -short -race -count=1 -timeout=60s ./...
go test -short -race -count=1 -run TestFoo ./internal/somepackage/
go build ./cmd/backend
go build ./cmd/runner
```
### Frontend (SvelteKit)
```bash
cd ui
npm run dev # Dev server at localhost:5173
npm run build # Production build
npm run check # svelte-check (type-check)
npm run paraglide # Regenerate i18n messages (run after editing messages/*.json)
```
## Architecture
Three services communicate via PocketBase records and a Redis/Valkey task queue:
**Backend** (`backend/cmd/backend`) — HTTP REST API. Handles reads, enqueues tasks to Redis via Asynq, returns presigned MinIO URLs. Minimal processing; delegates heavy work to the runner.
**Runner** (`backend/cmd/runner`) — Asynq task worker. Processes scraping, TTS audio generation, AI text/image generation. Reads/writes PocketBase and MinIO directly.
**UI** (`ui/`) — SvelteKit 2 + Svelte 5 SSR app. Consumes the backend API. Uses Paraglide JS for i18n (5 locales).
### Data layer
| Service | Role |
|---------|------|
| **PocketBase** (SQLite) | Auth, structured records (books, chapters, tasks, subscriptions) |
| **MinIO** (S3-compatible) | Object storage — chapter text, audio files, images |
| **Meilisearch** | Full-text search (runner indexes, backend reads) |
| **Redis/Valkey** | Asynq task queue + presigned URL cache |
### Key backend packages
- `internal/backend/` — HTTP handlers and server setup
- `internal/runner/` — Task processor implementations
- `internal/storage/` — Unified MinIO + PocketBase interface (all data access goes through here)
- `internal/orchestrator/` — Task orchestration across services
- `internal/taskqueue/` — Enqueue helpers (backend side)
- `internal/asynqqueue/` — Asynq queue setup (runner side)
- `internal/config/` — Environment variable loading (Doppler-injected at runtime, no .env files)
- `internal/presigncache/` — Redis cache for MinIO presigned URLs
### UI routing conventions (SvelteKit)
- `+page.svelte` / `+page.server.ts` — Page + server-side load
- `+layout.svelte` / `+layout.server.ts` — Layouts
- `routes/api/` — API routes (`+server.ts`)
- `lib/audio.svelte.ts` — Client-side audio playback store (Svelte 5 runes)
## Key Conventions
- **Svelte 5 runes only** — use `$state`, `$derived`, `$effect`; do not use Svelte 4 stores or reactive statements.
- **Modern Go idioms** — structured logging via `log/slog`, OpenTelemetry tracing throughout.
- **No direct MinIO/PocketBase client calls** outside the `internal/storage/` package.
- **Secrets via Doppler** — never use `.env` files. All secrets are injected by Doppler CLI.
- **CI/CD is Gitea Actions** (`.gitea/workflows/`), not GitHub Actions. Use `gitea.ref_name`/`gitea.sha` variables.
- **Git hooks** in `.githooks/` — enable with `just setup`.
- **i18n**: translation files live in `ui/messages/{en,es,fr,de,pt}.json`; run `npm run paraglide` after editing them.
- **Error tracking**: GlitchTip with per-service DSNs (backend id/2, runner id/3, UI id/1) stored in Doppler.

View File

@@ -177,6 +177,7 @@ func run() error {
DefaultVoice: cfg.Kokoro.DefaultVoice,
Version: version,
Commit: commit,
AdminToken: cfg.HTTP.AdminToken,
},
backend.Dependencies{
BookReader: store,
@@ -199,6 +200,7 @@ func run() error {
BookWriter: store,
AIJobStore: store,
BookAdminStore: store,
NotificationStore: store,
Log: log,
},
)

View File

@@ -1279,6 +1279,10 @@ func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
return
}
// Translated chapter content is immutable once generated — cache aggressively.
// The browser and any intermediary (CDN, SvelteKit fetch cache) can reuse this
// response for 1 hour without hitting MinIO again.
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
}

View File

@@ -3,8 +3,6 @@ package backend
import (
"encoding/json"
"net/http"
"github.com/libnovel/backend/internal/storage"
)
// handleDismissNotification handles DELETE /api/notifications/{id}.
@@ -14,12 +12,11 @@ func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Reques
jsonError(w, http.StatusBadRequest, "notification id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := store.DeleteNotification(r.Context(), id); err != nil {
if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
return
}
@@ -33,12 +30,11 @@ func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Requ
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := store.ClearAllNotifications(r.Context(), userID); err != nil {
if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
return
}
@@ -52,12 +48,11 @@ func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.R
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil {
if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
return
}
@@ -80,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
items, err := store.ListNotifications(r.Context(), userID, 50)
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return
@@ -97,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, item := range items {
b, _ := json.Marshal(item)
var n notification
json.Unmarshal(b, &n)
json.Unmarshal(b, &n) //nolint:errcheck
notifications = append(notifications, n)
}
@@ -111,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
}

View File

@@ -313,6 +313,8 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
// Mark job as done in PB, persisting results so the Review button works.
// Use context.Background() — r.Context() may be cancelled if the SSE client
// disconnected before processing finished, which would silently drop results.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
@@ -321,7 +323,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
resultsJSON, _ := json.Marshal(allResults)
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
req.Pattern, req.Slug, string(resultsJSON))
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
_ = s.deps.AIJobStore.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),

View File

@@ -94,6 +94,10 @@ type Dependencies struct {
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
// If nil, the admin book management endpoints return 503.
BookAdminStore bookstore.BookAdminStore
// NotificationStore manages per-user in-app notifications.
// Always wired directly to *storage.Store (not the Asynq wrapper) so
// notification endpoints work regardless of whether Redis/Asynq is in use.
NotificationStore bookstore.NotificationStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -107,6 +111,9 @@ type Config struct {
// Version and Commit are embedded in /health and /api/version responses.
Version string
Commit string
// AdminToken is the bearer token required for all /api/admin/* endpoints.
// When empty a startup warning is logged and admin routes are unprotected.
AdminToken string
}
// Server is the HTTP API server.
@@ -133,9 +140,30 @@ func New(cfg Config, deps Dependencies) *Server {
return &Server{cfg: cfg, deps: deps}
}
// requireAdmin returns a handler that enforces Bearer token authentication.
// When AdminToken is empty all requests are allowed through (with a warning logged
// once at startup via ListenAndServe).
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
next(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer "+s.cfg.AdminToken {
jsonError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
// ListenAndServe registers all routes and starts the HTTP server.
// It blocks until ctx is cancelled, then performs a graceful shutdown.
func (s *Server) ListenAndServe(ctx context.Context) error {
if s.cfg.AdminToken == "" {
s.deps.Log.Warn("backend: BACKEND_ADMIN_TOKEN is not set — /api/admin/* endpoints are unprotected")
}
mux := http.NewServeMux()
// Health / version
@@ -200,68 +228,73 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
// admin is a shorthand that wraps every /api/admin/* handler with bearer-token auth.
admin := func(pattern string, h http.HandlerFunc) {
mux.HandleFunc(pattern, s.requireAdmin(h))
}
// Admin translation endpoints
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Admin audio endpoints
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
admin("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
admin("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
admin("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Admin image generation endpoints
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
mux.HandleFunc("POST /api/admin/image-gen/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)
admin("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
admin("POST /api/admin/image-gen", s.handleAdminImageGen)
admin("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
admin("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
admin("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)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
admin("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
admin("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
admin("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
admin("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
admin("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
admin("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
admin("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin catalogue enrichment endpoints
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
admin("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
admin("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
admin("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
admin("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
admin("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
admin("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
admin("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
admin("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin AI job tracking endpoints
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
admin("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
admin("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
admin("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
// Auto-prompt generation from book/chapter content
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Admin book management (soft-delete / hard-delete)
mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
// Admin chapter split (imported books)
mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
// Import (PDF/EPUB)
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
admin("POST /api/admin/import", s.handleAdminImport)
admin("GET /api/admin/import", s.handleAdminImportList)
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
// Notifications
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)

View File

@@ -247,3 +247,14 @@ type ImportFileStore interface {
// GetImportChapters retrieves the pre-parsed chapters JSON.
GetImportChapters(ctx context.Context, key string) ([]byte, error)
}
// NotificationStore manages per-user in-app notifications.
// Always wired directly to the concrete *storage.Store so it works
// regardless of whether the Asynq task-queue wrapper is in use.
type NotificationStore interface {
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
MarkNotificationRead(ctx context.Context, id string) error
MarkAllNotificationsRead(ctx context.Context, userID string) error
DeleteNotification(ctx context.Context, id string) error
ClearAllNotifications(ctx context.Context, userID string) error
}

View File

@@ -92,6 +92,10 @@ type LibreTranslate struct {
type HTTP struct {
// Addr is the listen address, e.g. ":8080"
Addr string
// AdminToken is the bearer token required for all /api/admin/* endpoints.
// Set via BACKEND_ADMIN_TOKEN. When empty, admin endpoints are unprotected —
// only acceptable when the backend is unreachable from the public internet.
AdminToken string
}
// Meilisearch holds connection settings for the Meilisearch full-text search service.
@@ -242,7 +246,8 @@ func Load() Config {
},
HTTP: HTTP{
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
},
Runner: Runner{

View File

@@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
// ListNotifications returns notifications for a user.
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
filter := fmt.Sprintf("user_id='%s'", userID)
filter := fmt.Sprintf(`user_id=%q`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
if err != nil {
return nil, err
@@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error {
// ClearAllNotifications deletes all notifications for a user.
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
filter := fmt.Sprintf("user_id='%s'", userID)
filter := fmt.Sprintf(`user_id=%q`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil {
return fmt.Errorf("ClearAllNotifications list: %w", err)
@@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error
// MarkAllNotificationsRead marks all notifications for a user as read.
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
filter := fmt.Sprintf("user_id='%s'&&read=false", userID)
filter := fmt.Sprintf(`user_id=%q&&read=false`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil {
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)

View File

@@ -184,6 +184,7 @@ services:
environment:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
LOG_LEVEL: "${LOG_LEVEL}"
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
@@ -295,6 +296,7 @@ services:
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
AUTH_SECRET: "${AUTH_SECRET}"
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
# Valkey

View File

@@ -376,6 +376,13 @@ create "book_ratings" '{
{"name":"rating", "type":"number", "required":true}
]}'
create "site_config" '{
"name":"site_config","type":"base","fields":[
{"name":"decoration", "type":"text"},
{"name":"logoAnimation", "type":"text"},
{"name":"eventLabel", "type":"text"}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"

View File

@@ -250,6 +250,52 @@ html {
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}
/* ── Logo animation classes (used in nav + admin preview) ───────────── */
@keyframes logo-glow-pulse {
0%, 100% { text-shadow: 0 0 6px color-mix(in srgb, var(--color-brand) 60%, transparent); }
50% { text-shadow: 0 0 18px color-mix(in srgb, var(--color-brand) 90%, transparent), 0 0 32px color-mix(in srgb, var(--color-brand) 40%, transparent); }
}
.logo-anim-glow {
animation: logo-glow-pulse 2.4s ease-in-out infinite;
}
@keyframes logo-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.logo-anim-shimmer {
background: linear-gradient(
90deg,
var(--color-brand) 0%,
color-mix(in srgb, var(--color-brand) 40%, white) 40%,
var(--color-brand) 50%,
color-mix(in srgb, var(--color-brand) 40%, white) 60%,
var(--color-brand) 100%
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: logo-shimmer 2.2s linear infinite;
}
@keyframes logo-pulse-scale {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.logo-anim-pulse {
display: inline-block;
animation: logo-pulse-scale 1.8s ease-in-out infinite;
}
@keyframes logo-rainbow {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
.logo-anim-rainbow {
animation: logo-rainbow 4s linear infinite;
}
/* ── Respect reduced motion — disable all decorative animations ─────── */
@media (prefers-reduced-motion: reduce) {
*,

View File

@@ -0,0 +1,285 @@
<script lang="ts">
/**
* SeasonalDecoration — full-viewport canvas particle overlay.
*
* Modes:
* snow — white circular snowflakes drifting down with gentle sway
* sakura — pink/white ellipse petals falling and rotating
* fireflies — small glowing dots floating up, pulsing opacity
* leaves — orange/red/yellow tear-drop shapes tumbling down
* stars — white stars twinkling in place (fixed positions, opacity animation)
*/
type Mode = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars';
interface Props { mode: Mode }
let { mode }: Props = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let raf = 0;
// ── Particle types ──────────────────────────────────────────────────────
interface Particle {
x: number; y: number; r: number;
vx: number; vy: number;
angle: number; vAngle: number;
opacity: number; vOpacity: number;
color: string;
// star-specific
twinkleOffset?: number;
}
// ── Palette helpers ──────────────────────────────────────────────────────
function rand(min: number, max: number) { return min + Math.random() * (max - min); }
function randInt(min: number, max: number) { return Math.floor(rand(min, max + 1)); }
const SNOW_COLORS = ['rgba(255,255,255,0.85)', 'rgba(200,220,255,0.75)', 'rgba(220,235,255,0.8)'];
const SAKURA_COLORS = ['rgba(255,182,193,0.85)', 'rgba(255,200,210,0.8)', 'rgba(255,240,245,0.9)', 'rgba(255,160,180,0.75)'];
const FIREFLY_COLORS = ['rgba(180,255,100,0.9)', 'rgba(220,255,150,0.85)', 'rgba(255,255,180,0.8)'];
const LEAF_COLORS = ['rgba(210,80,20,0.85)', 'rgba(190,120,30,0.8)', 'rgba(220,160,40,0.85)', 'rgba(180,60,10,0.8)', 'rgba(240,140,30,0.9)'];
const STAR_COLORS = ['rgba(255,255,255,0.9)', 'rgba(255,240,180,0.85)', 'rgba(180,210,255,0.8)'];
// ── Spawn helpers ────────────────────────────────────────────────────────
function spawnSnow(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(1.5, 5),
vx: rand(-0.4, 0.4), vy: rand(0.6, 2.0),
angle: 0, vAngle: 0,
opacity: rand(0.5, 1), vOpacity: 0,
color: SNOW_COLORS[randInt(0, SNOW_COLORS.length - 1)],
};
}
function spawnSakura(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(3, 7),
vx: rand(-0.6, 0.6), vy: rand(0.5, 1.6),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.03, 0.03),
opacity: rand(0.6, 1), vOpacity: 0,
color: SAKURA_COLORS[randInt(0, SAKURA_COLORS.length - 1)],
};
}
function spawnFirefly(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(H * 0.3, H),
r: rand(1.5, 3.5),
vx: rand(-0.3, 0.3), vy: rand(-0.8, -0.2),
angle: 0, vAngle: 0,
opacity: rand(0.2, 0.8), vOpacity: rand(0.008, 0.025) * (Math.random() < 0.5 ? 1 : -1),
color: FIREFLY_COLORS[randInt(0, FIREFLY_COLORS.length - 1)],
};
}
function spawnLeaf(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(4, 9),
vx: rand(-1.2, 1.2), vy: rand(0.8, 2.5),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.05, 0.05),
opacity: rand(0.6, 1), vOpacity: 0,
color: LEAF_COLORS[randInt(0, LEAF_COLORS.length - 1)],
};
}
function spawnStar(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(0, H),
r: rand(0.8, 2.5),
vx: 0, vy: 0,
angle: 0, vAngle: 0,
opacity: rand(0.1, 0.9),
vOpacity: rand(0.004, 0.015) * (Math.random() < 0.5 ? 1 : -1),
color: STAR_COLORS[randInt(0, STAR_COLORS.length - 1)],
twinkleOffset: rand(0, Math.PI * 2),
};
}
// ── Draw helpers ─────────────────────────────────────────────────────────
function drawSnow(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawSakura(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.ellipse(0, 0, p.r * 1.8, p.r, 0, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawFirefly(ctx: CanvasRenderingContext2D, p: Particle) {
// Glow effect: large soft circle + small bright core
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4);
grd.addColorStop(0, p.color);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 4, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.globalAlpha = p.opacity * 0.6;
ctx.fill();
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawLeaf(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.5);
ctx.bezierCurveTo(p.r * 1.2, -p.r * 0.5, p.r * 1.2, p.r * 0.5, 0, p.r * 1.5);
ctx.bezierCurveTo(-p.r * 1.2, p.r * 0.5, -p.r * 1.2, -p.r * 0.5, 0, -p.r * 1.5);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawStar(ctx: CanvasRenderingContext2D, p: Particle, t: number) {
const pulse = 0.5 + 0.5 * Math.sin(t * 0.002 + (p.twinkleOffset ?? 0));
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity * pulse;
ctx.fill();
}
// ── Particle count by mode ────────────────────────────────────────────────
const COUNT: Record<Mode, number> = {
snow: 120, sakura: 60, fireflies: 50, leaves: 45, stars: 150,
};
// ── Main effect ──────────────────────────────────────────────────────────
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let W = window.innerWidth;
let H = window.innerHeight;
canvas.width = W;
canvas.height = H;
const onResize = () => {
W = window.innerWidth;
H = window.innerHeight;
canvas!.width = W;
canvas!.height = H;
// Reseed stars on resize since they're positionally fixed
if (mode === 'stars') {
particles.length = 0;
for (let i = 0; i < COUNT.stars; i++) particles.push(spawnStar(W, H));
}
};
window.addEventListener('resize', onResize);
const n = COUNT[mode];
const particles: Particle[] = [];
// Pre-scatter initial particles across the full height
for (let i = 0; i < n; i++) {
let p: Particle;
switch (mode) {
case 'snow': p = spawnSnow(W, H); p.y = rand(0, H); break;
case 'sakura': p = spawnSakura(W, H); p.y = rand(0, H); break;
case 'fireflies': p = spawnFirefly(W, H); break;
case 'leaves': p = spawnLeaf(W, H); p.y = rand(0, H); break;
case 'stars': p = spawnStar(W, H); break;
}
particles.push(p);
}
let t = 0;
function tick() {
ctx!.clearRect(0, 0, W, H);
ctx!.save();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
switch (mode) {
case 'snow': {
// Gentle horizontal sway
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 0.5;
p.x += p.vx; p.y += p.vy;
if (p.y > H + 10) particles[i] = spawnSnow(W, H);
else drawSnow(ctx!, p);
break;
}
case 'sakura': {
p.vx = Math.sin(t * 0.0008 + p.y * 0.008) * 0.8;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnSakura(W, H);
else drawSakura(ctx!, p);
break;
}
case 'fireflies': {
p.x += p.vx + Math.sin(t * 0.002 + i) * 0.3;
p.y += p.vy;
p.opacity += p.vOpacity;
if (p.opacity >= 1) { p.opacity = 1; p.vOpacity *= -1; }
if (p.opacity <= 0.1) { p.opacity = 0.1; p.vOpacity *= -1; }
if (p.y < -10) particles[i] = spawnFirefly(W, H);
else drawFirefly(ctx!, p);
break;
}
case 'leaves': {
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 1.2 + p.vx * 0.02;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnLeaf(W, H);
else drawLeaf(ctx!, p);
break;
}
case 'stars': {
drawStar(ctx!, p, t);
break;
}
}
}
ctx!.restore();
t++;
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
});
</script>
<!--
Fixed full-viewport overlay, pointer-events-none so all clicks pass through.
z-index 40 keeps it below the sticky nav (z-50) but above page content.
-->
<canvas
bind:this={canvas}
class="fixed inset-0 z-40 pointer-events-none"
aria-hidden="true"
></canvas>

View File

@@ -2498,6 +2498,90 @@ export async function getUserStats(
};
}
// ─── Site Config ─────────────────────────────────────────────────────────────
//
// A single singleton record in the `site_config` collection holds global
// display settings (seasonal decoration, logo animation, etc.).
// The record is lazily created on first write; reads return safe defaults if
// the collection/record doesn't exist yet.
export interface SiteConfig {
/** Seasonal decoration particle effect: null = off */
decoration: 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
/** Special CSS class applied to the nav logo text */
logoAnimation: 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
/** Human-readable label for the current event/season shown in a small badge */
eventLabel: string;
}
const SITE_CONFIG_DEFAULTS: SiteConfig = {
decoration: null,
logoAnimation: 'none',
eventLabel: '',
};
// In-memory short cache so every SSR request doesn't hammer PocketBase
let _siteConfigCache: { value: SiteConfig; exp: number } | null = null;
const SITE_CONFIG_CACHE_TTL = 60_000; // 60 seconds
export async function getSiteConfig(): Promise<SiteConfig> {
if (_siteConfigCache && Date.now() < _siteConfigCache.exp) {
return _siteConfigCache.value;
}
try {
const list = await pbGet<{ items: Array<{ id: string } & SiteConfig> }>(
'/api/collections/site_config/records?perPage=1'
);
const row = list.items?.[0];
const value: SiteConfig = row
? {
decoration: row.decoration ?? null,
logoAnimation: row.logoAnimation ?? 'none',
eventLabel: row.eventLabel ?? '',
}
: { ...SITE_CONFIG_DEFAULTS };
_siteConfigCache = { value, exp: Date.now() + SITE_CONFIG_CACHE_TTL };
return value;
} catch {
// Collection may not exist yet — return defaults silently
return { ...SITE_CONFIG_DEFAULTS };
}
}
export async function saveSiteConfig(patch: Partial<SiteConfig>): Promise<void> {
// Bust cache
_siteConfigCache = null;
// Ensure collection exists and find the singleton record
let existingId: string | null = null;
try {
const list = await pbGet<{ items: Array<{ id: string }> }>(
'/api/collections/site_config/records?perPage=1'
);
existingId = list.items?.[0]?.id ?? null;
} catch {
// Collection doesn't exist yet — create it via PocketBase API
await pbPost('/api/collections', {
name: 'site_config',
type: 'base',
fields: [
{ name: 'decoration', type: 'text' },
{ name: 'logoAnimation', type: 'text' },
{ name: 'eventLabel', type: 'text' },
],
});
}
if (existingId) {
await pbPatch(`/api/collections/site_config/records/${existingId}`, patch);
} else {
await pbPost('/api/collections/site_config/records', {
...SITE_CONFIG_DEFAULTS,
...patch,
});
}
}
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';

View File

@@ -15,18 +15,31 @@ import { env } from '$env/dynamic/private';
import * as cache from '$lib/server/cache';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
const ADMIN_TOKEN = env.BACKEND_ADMIN_TOKEN ?? '';
/**
* Fetch a path on the backend, throwing a 502 on network failures.
*
* The `path` must start with `/` (e.g. `/api/voices`).
* Requests to `/api/admin/*` automatically include the Bearer token from
* the BACKEND_ADMIN_TOKEN environment variable.
*
* SvelteKit `error()` exceptions are always re-thrown so callers can
* short-circuit correctly inside their own catch blocks.
*/
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
let finalInit = init;
if (ADMIN_TOKEN && path.startsWith('/api/admin')) {
finalInit = {
...init,
headers: {
Authorization: `Bearer ${ADMIN_TOKEN}`,
...((init?.headers ?? {}) as Record<string, string>)
}
};
}
try {
return await fetch(`${BACKEND_URL}${path}`, init);
return await fetch(`${BACKEND_URL}${path}`, finalInit);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getSettings } from '$lib/server/pocketbase';
import { getSettings, getSiteConfig } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in
@@ -60,6 +60,11 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
return {
user: locals.user,
isPro: locals.isPro,
settings
settings,
siteConfig: await getSiteConfig().catch(() => ({
decoration: null as null,
logoAnimation: 'none' as const,
eventLabel: '',
})),
};
};

View File

@@ -14,6 +14,7 @@
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
import SeasonalDecoration from '$lib/components/SeasonalDecoration.svelte';
import { fly, fade } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -94,6 +95,20 @@
let listeningModeOpen = $state(false);
let listeningModeChapters = $state(false);
// ── Site config (seasonal decoration + logo animation) ──────────────────
// svelte-ignore state_referenced_locally
let siteDecoration = $state(data.siteConfig?.decoration ?? null);
// svelte-ignore state_referenced_locally
let siteLogoAnim = $state(data.siteConfig?.logoAnimation ?? 'none');
// svelte-ignore state_referenced_locally
let siteEventLabel = $state(data.siteConfig?.eventLabel ?? '');
// Refresh when invalidateAll() re-runs layout load (e.g. after admin saves)
$effect(() => {
siteDecoration = data.siteConfig?.decoration ?? null;
siteLogoAnim = data.siteConfig?.logoAnimation ?? 'none';
siteEventLabel = data.siteConfig?.eventLabel ?? '';
});
// Build time formatted in the user's local timezone (populated on mount so
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
let buildTimeLocal = $state('');
@@ -522,8 +537,18 @@
{/if}
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0 flex items-center gap-1.5
{siteLogoAnim === 'glow' ? 'logo-anim-glow' : ''}
{siteLogoAnim === 'shimmer' ? 'logo-anim-shimmer' : ''}
{siteLogoAnim === 'pulse' ? 'logo-anim-pulse' : ''}
{siteLogoAnim === 'rainbow' ? 'logo-anim-rainbow' : ''}
">
libnovel
{#if siteEventLabel}
<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none tracking-wide">
{siteEventLabel}
</span>
{/if}
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
@@ -1173,3 +1198,13 @@
searchOpen = true;
}
}} />
<!-- Seasonal decoration overlay — rendered above page content, below nav -->
{#if siteDecoration}
<SeasonalDecoration mode={siteDecoration} />
{/if}
<style>
/* Logo animation keyframes are defined globally in app.css */
/* This block intentionally left minimal — all logo-anim-* classes live in app.css */
</style>

View File

@@ -52,6 +52,11 @@
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" />`
},
{
href: '/admin/site-theme',
label: () => 'Site Theme',
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />`
}
];

View File

@@ -6,8 +6,7 @@ export type { AIJob };
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
// Stream jobs so navigation is instant; list populates a moment later.
const jobs = listAIJobs().catch((e): AIJob[] => {
const jobs = await listAIJobs().catch((e): AIJob[] => {
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
return [];
});

View File

@@ -9,9 +9,9 @@
let jobs = $state<AIJob[]>([]);
// Resolve streamed promise on load and on server reloads (invalidateAll)
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
$effect(() => {
data.jobs.then((resolved) => { jobs = resolved; });
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getSiteConfig } from '$lib/server/pocketbase';
export const load: PageServerLoad = async () => {
const config = await getSiteConfig();
return { config };
};

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type Decoration = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
type LogoAnimation = 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
let decoration = $state<Decoration>(data.config.decoration);
let logoAnimation = $state<LogoAnimation>(data.config.logoAnimation);
let eventLabel = $state(data.config.eventLabel ?? '');
let saving = $state(false);
let saved = $state(false);
let errMsg = $state('');
const DECORATIONS: { id: Decoration; label: string; emoji: string; desc: string }[] = [
{ id: null, label: 'Off', emoji: '✕', desc: 'No decoration' },
{ id: 'snow', label: 'Snow', emoji: '❄️', desc: 'Falling snowflakes — winter' },
{ id: 'sakura', label: 'Sakura', emoji: '🌸', desc: 'Cherry blossom petals — spring' },
{ id: 'fireflies', label: 'Fireflies', emoji: '✨', desc: 'Glowing fireflies — summer' },
{ id: 'leaves', label: 'Leaves', emoji: '🍂', desc: 'Falling autumn leaves — fall' },
{ id: 'stars', label: 'Stars', emoji: '⭐', desc: 'Twinkling stars — events / fantasy' },
];
const LOGO_ANIMATIONS: { id: LogoAnimation; label: string; desc: string }[] = [
{ id: 'none', label: 'None', desc: 'Default brand colour, no animation' },
{ id: 'glow', label: 'Glow', desc: 'Soft pulsing amber glow' },
{ id: 'shimmer', label: 'Shimmer', desc: 'Left-to-right shine sweep' },
{ id: 'pulse', label: 'Pulse', desc: 'Subtle scale pulse' },
{ id: 'rainbow', label: 'Rainbow', desc: 'Slow hue-rotate colour cycle' },
];
async function save() {
saving = true; saved = false; errMsg = '';
try {
const res = await fetch('/api/site-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decoration, logoAnimation, eventLabel }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
errMsg = d.message ?? `Error ${res.status}`;
} else {
saved = true;
setTimeout(() => { saved = false; }, 3000);
}
} catch (e) {
errMsg = String(e);
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Site Theme — Admin</title>
</svelte:head>
<div class="max-w-2xl">
<div class="mb-8">
<h1 class="text-2xl font-bold text-(--color-text) mb-1">Site Theme</h1>
<p class="text-sm text-(--color-muted)">
Control seasonal decorations and the nav logo animation globally.
Changes take effect for all users within ~60 seconds (server cache TTL).
</p>
</div>
<!-- ── Decoration ────────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Particle Decoration</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each DECORATIONS as d}
<button
type="button"
onclick={() => { decoration = d.id; }}
class="flex items-start gap-3 p-3 rounded-lg border text-left transition-all
{decoration === d.id
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
>
<span class="text-xl leading-none shrink-0 mt-0.5">{d.emoji}</span>
<div class="min-w-0">
<p class="text-sm font-semibold leading-snug">{d.label}</p>
<p class="text-xs opacity-70 leading-snug mt-0.5">{d.desc}</p>
</div>
</button>
{/each}
</div>
</section>
<!-- ── Logo Animation ────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Logo Animation</h2>
<div class="flex flex-col gap-2">
{#each LOGO_ANIMATIONS as a}
<button
type="button"
onclick={() => { logoAnimation = a.id; }}
class="flex items-center gap-4 p-3 rounded-lg border text-left transition-all
{logoAnimation === a.id
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
>
<!-- Preview of the logo text with the animation class applied -->
<span class="font-bold text-lg tracking-tight w-24 shrink-0 text-(--color-brand)
{a.id === 'glow' ? 'logo-anim-glow' : ''}
{a.id === 'shimmer' ? 'logo-anim-shimmer' : ''}
{a.id === 'pulse' ? 'logo-anim-pulse' : ''}
{a.id === 'rainbow' ? 'logo-anim-rainbow' : ''}
">libnovel</span>
<div>
<p class="text-sm font-semibold">{a.label}</p>
<p class="text-xs opacity-70">{a.desc}</p>
</div>
{#if logoAnimation === a.id}
<svg class="w-4 h-4 ml-auto shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</button>
{/each}
</div>
</section>
<!-- ── Event Label ───────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-1">Event Label <span class="normal-case font-normal text-(--color-muted)">(optional)</span></h2>
<p class="text-xs text-(--color-muted) mb-3">Short text shown as a small badge next to the logo, e.g. "Winter 2025" or "Sakura Festival". Leave blank to hide.</p>
<input
type="text"
maxlength="64"
placeholder="e.g. Winter 2025"
bind:value={eventLabel}
class="w-full px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand)/60"
/>
</section>
<!-- ── Save ──────────────────────────────────────────────────────────── -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={save}
disabled={saving}
class="px-5 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
{#if saved}
<span class="text-sm text-green-400 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
Saved
</span>
{/if}
{#if errMsg}
<span class="text-sm text-red-400">{errMsg}</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSiteConfig, saveSiteConfig } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/site-config
* Public — returns current site-wide decoration/animation settings.
*/
export const GET: RequestHandler = async () => {
try {
const config = await getSiteConfig();
return json(config);
} catch (e) {
log.error('site-config', 'GET failed', { err: String(e) });
error(500, 'Failed to load site config');
}
};
/**
* PUT /api/site-config
* Admin only — updates decoration + logoAnimation + eventLabel.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
error(403, 'Forbidden');
}
const body = await request.json().catch(() => null);
if (!body) error(400, 'Invalid JSON body');
const validDecorations = ['snow', 'sakura', 'fireflies', 'leaves', 'stars', null];
if (body.decoration !== undefined && !validDecorations.includes(body.decoration)) {
error(400, `Invalid decoration — must be one of: ${validDecorations.filter(Boolean).join(', ')}, or null`);
}
const validLogoAnimations = ['none', 'glow', 'rainbow', 'pulse', 'shimmer'];
if (body.logoAnimation !== undefined && !validLogoAnimations.includes(body.logoAnimation)) {
error(400, `Invalid logoAnimation — must be one of: ${validLogoAnimations.join(', ')}`);
}
if (body.eventLabel !== undefined && typeof body.eventLabel !== 'string') {
error(400, 'eventLabel must be a string');
}
try {
await saveSiteConfig({
decoration: body.decoration ?? null,
logoAnimation: body.logoAnimation ?? 'none',
eventLabel: typeof body.eventLabel === 'string' ? body.eventLabel.slice(0, 64) : '',
});
return json({ ok: true });
} catch (e) {
log.error('site-config', 'PUT failed', { err: String(e) });
error(500, 'Failed to save site config');
}
};

View File

@@ -23,9 +23,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
return new Response(null, { status: res.status });
}
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// Forward the immutable cache header from the backend so browsers and CDNs
// can cache translated chapter content without hitting MinIO on every load.
const cc = res.headers.get('Cache-Control');
if (cc) headers['Cache-Control'] = cc;
return new Response(JSON.stringify(data), { headers });
};
export const POST: RequestHandler = async ({ params, url, locals }) => {

View File

@@ -53,11 +53,6 @@
filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug))
);
function enterSelectMode(slug: string) {
selectMode = true;
selected = new Set([slug]);
}
function exitSelectMode() {
selectMode = false;
selected = new Set();
@@ -81,43 +76,11 @@
}
}
// Long-press support (pointer events, works on desktop + mobile)
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressFired = false;
function onPointerDown(slug: string) {
if (selectMode) return;
longPressFired = false;
longPressTimer = setTimeout(() => {
longPressFired = true;
enterSelectMode(slug);
}, 500);
}
function onPointerUp() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function onPointerCancel() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Prevent navigation click if long-press just fired
// Card click: in selection mode, toggle selection; otherwise navigate normally
function onCardClick(e: MouseEvent, slug: string) {
if (selectMode) {
e.preventDefault();
toggleSelect(slug);
return;
}
if (longPressFired) {
e.preventDefault();
longPressFired = false;
}
}
@@ -175,6 +138,14 @@
>
Cancel
</button>
{:else if data.books?.length}
<button
type="button"
onclick={() => { selectMode = true; }}
class="text-sm text-(--color-muted) hover:text-(--color-text) transition-colors pt-1"
>
Select
</button>
{/if}
</div>
@@ -228,9 +199,6 @@
<a
href="/books/{book.slug}"
onclick={(e) => onCardClick(e, book.slug)}
onpointerdown={() => onPointerDown(book.slug)}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
draggable="false"
class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none
{isSelected

View File

@@ -60,10 +60,17 @@
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
// Resolved books from streamed promise (populated in {#await} block via binding trick)
// Resolved books from streamed promises — populated via $effect once promises settle
let resolvedBooks = $state<Book[]>([]);
let resolvedVotedBooks = $state<VotedBook[]>([]);
$effect(() => {
Promise.all([data.streamed.books, data.streamed.votedBooks]).then(([books, vb]) => {
resolvedBooks = books as Book[];
resolvedVotedBooks = vb as VotedBook[];
});
});
let deck = $derived.by(() => {
let books = resolvedBooks;
if (prefs.onboarded && prefs.genres.length > 0) {
@@ -479,12 +486,6 @@
</div>
{/if}
<!-- ── Await streamed data ─────────────────────────────────────────────────────── -->
{#await Promise.all([data.streamed.books, data.streamed.votedBooks]) then [books, vb]}
<!-- Silently populate reactive state once data resolves -->
{@const _ = (() => { resolvedBooks = books as Book[]; resolvedVotedBooks = vb as VotedBook[]; return ''; })()}
{/await}
<!-- ── Page layout ────────────────────────────────────────────────────────────── -->
<div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)]
lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]">