Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9977744a | ||
|
|
9f1c82fe05 | ||
|
|
419bb7e366 | ||
|
|
734ba68eed | ||
|
|
708f8bcd6f | ||
|
|
7009b24568 | ||
|
|
5b90667b4b | ||
|
|
dec11f0c01 | ||
|
|
0f1ded2269 | ||
|
|
2473a0213e | ||
|
|
1064c784d4 | ||
|
|
ed9eeb6262 |
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal 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.
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
*,
|
||||
|
||||
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal file
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal 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>
|
||||
@@ -44,6 +44,7 @@ export interface Book {
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
meta_updated: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ChapterIdx {
|
||||
@@ -2497,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -136,18 +136,24 @@
|
||||
|
||||
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
{@const stackBooks = heroBooks.length > 1
|
||||
? Array.from({ length: Math.min(heroBooks.length - 1, 3) }, (_, i) =>
|
||||
heroBooks[(heroIndex + 1 + i) % heroBooks.length])
|
||||
: []}
|
||||
<section class="mb-6">
|
||||
<div class="relative">
|
||||
<!-- Card — swipe to navigate -->
|
||||
<!-- Outer flex row: front card + queued book spines (sm+ only) -->
|
||||
<div class="relative flex items-stretch gap-0">
|
||||
|
||||
<!-- Front card — swipe to navigate -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all z-[2] flex-1 min-w-0"
|
||||
ontouchstart={onSwipeStart}
|
||||
ontouchend={onSwipeEnd}
|
||||
>
|
||||
<!-- Cover -->
|
||||
<!-- Cover — drives card height via aspect-[2/3] -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
@@ -162,19 +168,20 @@
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1 overflow-hidden
|
||||
h-[calc(128px*3/2)] sm:h-[calc(176px*3/2)]">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
<p class="text-sm text-(--color-muted) truncate">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -197,23 +204,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
>
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Queued book spines — visible sm+ only, peek to the right of the front card -->
|
||||
{#each stackBooks as stackBook, i}
|
||||
{@const opacity = i === 0 ? 'opacity-70' : 'opacity-40'}
|
||||
{@const width = i === 0 ? 'sm:w-10' : 'sm:w-7'}
|
||||
<a
|
||||
href="/books/{stackBook.book.slug}/chapters/{stackBook.chapter}"
|
||||
class="hidden sm:block shrink-0 {width} rounded-r-xl overflow-hidden border border-l-0 border-(--color-border) {opacity} hover:opacity-90 transition-opacity"
|
||||
aria-label={stackBook.book.title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if stackBook.book.cover}
|
||||
<img src={stackBook.book.cover} alt="" aria-hidden="true"
|
||||
class="w-full h-full object-cover object-left" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
>
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -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" />`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
});
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal 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 };
|
||||
};
|
||||
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
160
ui/src/routes/admin/site-theme/+page.svelte
Normal 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>
|
||||
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* PATCH /api/admin/books/[slug]/archive
|
||||
* PATCH /api/admin/books/[slug]/unarchive (action param: ?action=unarchive)
|
||||
*
|
||||
* Admin-only proxy. Soft-deletes (archives) or restores a book.
|
||||
* Returns { slug, status: "archived" | "active" }.
|
||||
*/
|
||||
|
||||
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 PATCH: RequestHandler = async ({ params, url, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const action = url.searchParams.get('action') === 'unarchive' ? 'unarchive' : 'archive';
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/${action}`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/archive', 'backend proxy error', { slug, action, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* DELETE /api/admin/books/[slug]/delete
|
||||
*
|
||||
* Admin-only proxy. Permanently removes a book and all its data:
|
||||
* PocketBase records, MinIO objects, and the Meilisearch document.
|
||||
* This operation is irreversible — use the archive endpoint for soft-deletion.
|
||||
* Returns { slug, status: "deleted" }.
|
||||
*/
|
||||
|
||||
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 DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/delete', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
57
ui/src/routes/api/site-config/+server.ts
Normal file
57
ui/src/routes/api/site-config/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,7 +95,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
total_chapters: preview.meta.total_chapters,
|
||||
source_url: preview.meta.source_url,
|
||||
ranking: 0,
|
||||
meta_updated: ''
|
||||
meta_updated: '',
|
||||
archived: false
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -616,6 +616,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: archive / delete ───────────────────────────────────────────────
|
||||
let archiveStatus = $state<'idle' | 'busy' | 'done' | 'error'>('idle');
|
||||
let deleteStatus = $state<'idle' | 'busy' | 'confirm' | 'done' | 'error'>('idle');
|
||||
let bookArchived = $state(data.book?.archived ?? false);
|
||||
|
||||
async function toggleArchive() {
|
||||
const slug = data.book?.slug;
|
||||
if (!slug) return;
|
||||
archiveStatus = 'busy';
|
||||
const action = bookArchived ? 'unarchive' : 'archive';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/books/${encodeURIComponent(slug)}/archive?action=${action}`,
|
||||
{ method: 'PATCH' }
|
||||
);
|
||||
if (res.ok) {
|
||||
bookArchived = !bookArchived;
|
||||
archiveStatus = 'done';
|
||||
setTimeout(() => { archiveStatus = 'idle'; }, 3000);
|
||||
} else {
|
||||
archiveStatus = 'error';
|
||||
}
|
||||
} catch {
|
||||
archiveStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook() {
|
||||
const slug = data.book?.slug;
|
||||
if (!slug) return;
|
||||
deleteStatus = 'busy';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/books/${encodeURIComponent(slug)}/delete`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
deleteStatus = 'done';
|
||||
// Navigate away — book no longer exists
|
||||
setTimeout(() => { goto('/admin/catalogue-tools'); }, 1500);
|
||||
} else {
|
||||
deleteStatus = 'error';
|
||||
}
|
||||
} catch {
|
||||
deleteStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// ── "More like this" ─────────────────────────────────────────────────────
|
||||
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
|
||||
let similarBooks = $state<SimilarBook[]>([]);
|
||||
@@ -1525,10 +1573,83 @@
|
||||
{/if}
|
||||
{#if audioError}
|
||||
<span class="text-xs text-(--color-muted)">{audioError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Archive / Delete -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Danger Zone</p>
|
||||
|
||||
<!-- Archive / Unarchive -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveStatus === 'busy'}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border transition-colors
|
||||
{archiveStatus === 'busy'
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border-(--color-border)'
|
||||
: bookArchived
|
||||
? 'bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 border-amber-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2) border-(--color-border)'}"
|
||||
>
|
||||
{#if archiveStatus === 'busy'}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else if bookArchived}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{/if}
|
||||
{bookArchived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
{#if archiveStatus === 'done'}
|
||||
<span class="text-xs text-green-400">{bookArchived ? 'Book archived — hidden from search.' : 'Book restored — visible again.'}</span>
|
||||
{:else if archiveStatus === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">Action failed.</span>
|
||||
{:else if bookArchived}
|
||||
<span class="text-xs text-amber-400/70">This book is archived and hidden from all users.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hard delete -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
{#if deleteStatus === 'confirm'}
|
||||
<span class="text-xs text-(--color-danger)">This will permanently delete all chapters, audio, and cover. Cannot be undone.</span>
|
||||
<button
|
||||
onclick={deleteBook}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 border border-red-600/30 transition-colors"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button onclick={() => { deleteStatus = 'idle'; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Cancel</button>
|
||||
{:else if deleteStatus === 'busy'}
|
||||
<button disabled class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border border-(--color-border)">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Deleting…
|
||||
</button>
|
||||
{:else if deleteStatus === 'done'}
|
||||
<span class="text-xs text-green-400">Book deleted. Redirecting…</span>
|
||||
{:else if deleteStatus === 'error'}
|
||||
<button onclick={() => { deleteStatus = 'confirm'; }} class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-(--color-surface-2) border border-(--color-border) transition-colors">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete failed — retry?
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { deleteStatus = 'confirm'; }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-red-600/10 border border-(--color-border) transition-colors"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete book
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -9,9 +9,12 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const [books, votedBooks] = await Promise.all([
|
||||
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
|
||||
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
|
||||
]);
|
||||
return { books, votedBooks };
|
||||
// Return promises directly — SvelteKit streams them, so the page transitions
|
||||
// immediately and content resolves async (skeleton shown while loading).
|
||||
return {
|
||||
streamed: {
|
||||
books: getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
|
||||
votedBooks: getVotedBooks(locals.sessionId, locals.user?.id).catch(() => []),
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,8 +60,19 @@
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
// 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 = data.books as Book[];
|
||||
let books = resolvedBooks;
|
||||
if (prefs.onboarded && prefs.genres.length > 0) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const filtered = books.filter((b) => {
|
||||
@@ -85,19 +96,14 @@
|
||||
let offsetY = $state(0);
|
||||
let transitioning = $state(false);
|
||||
let showPreview = $state(false);
|
||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
||||
let voted = $state<{ slug: string; action: string } | null>(null);
|
||||
let showHistory = $state(false);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
|
||||
|
||||
// Keep in sync if server data refreshes
|
||||
$effect(() => {
|
||||
votedBooks = data.votedBooks ?? [];
|
||||
});
|
||||
let votedBooks = $state<VotedBook[]>([]);
|
||||
// Sync when streamed data resolves
|
||||
$effect(() => { if (resolvedVotedBooks.length) votedBooks = resolvedVotedBooks; });
|
||||
|
||||
async function undoVote(slug: string) {
|
||||
// Optimistic update
|
||||
votedBooks = votedBooks.filter((v) => v.slug !== slug);
|
||||
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -107,11 +113,19 @@
|
||||
let currentBook = $derived(deck[idx] as Book | undefined);
|
||||
let nextBook = $derived(deck[idx + 1] as Book | undefined);
|
||||
let nextNextBook = $derived(deck[idx + 2] as Book | undefined);
|
||||
let deckEmpty = $derived(!currentBook);
|
||||
let deckEmpty = $derived(resolvedBooks.length > 0 && !currentBook);
|
||||
let loading = $derived(resolvedBooks.length === 0);
|
||||
let totalRemaining = $derived(Math.max(0, deck.length - idx));
|
||||
|
||||
// Which direction/indicator to show
|
||||
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | 'nope' | null => {
|
||||
// Preload next card image
|
||||
$effect(() => {
|
||||
if (!browser || !nextBook?.cover) return;
|
||||
const img = new Image();
|
||||
img.src = nextBook.cover;
|
||||
});
|
||||
|
||||
// Which direction/indicator to show (no NOPE — only 3 actions)
|
||||
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | null => {
|
||||
if (!isDragging) return null;
|
||||
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
|
||||
const threshold = 20;
|
||||
@@ -120,7 +134,6 @@
|
||||
if (offsetX < -threshold) return 'skip';
|
||||
} else {
|
||||
if (offsetY < -threshold) return 'read_now';
|
||||
if (offsetY > threshold) return 'nope';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -134,7 +147,7 @@
|
||||
|
||||
let cardEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
|
||||
// ── Card entry animation ──────────────────────────────────────────────────
|
||||
let cardEntering = $state(false);
|
||||
let entryTransition = $state(false);
|
||||
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -201,14 +214,12 @@
|
||||
|
||||
if (ax > ay && ax > THRESHOLD_X) {
|
||||
await doAction(offsetX > 0 ? 'like' : 'skip');
|
||||
} else if (ay > ax && ay > THRESHOLD_Y) {
|
||||
await doAction(offsetY < 0 ? 'read_now' : 'nope');
|
||||
} else if (ay > ax && ay > THRESHOLD_Y && offsetY < 0) {
|
||||
await doAction('read_now');
|
||||
} else if (!hasMoved) {
|
||||
// Tap without drag → preview
|
||||
showPreview = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
} else {
|
||||
// Snap back
|
||||
transitioning = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
await delay(320);
|
||||
@@ -218,13 +229,12 @@
|
||||
|
||||
function delay(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
|
||||
|
||||
type VoteAction = 'like' | 'skip' | 'nope' | 'read_now';
|
||||
type VoteAction = 'like' | 'skip' | 'read_now';
|
||||
|
||||
const flyTargets: Record<VoteAction, { x: number; y: number }> = {
|
||||
like: { x: 1300, y: -80 },
|
||||
skip: { x: -1300, y: -80 },
|
||||
read_now: { x: 30, y: -1300 },
|
||||
nope: { x: 0, y: 1300 }
|
||||
};
|
||||
|
||||
async function doAction(action: VoteAction) {
|
||||
@@ -232,15 +242,12 @@
|
||||
animating = true;
|
||||
const book = currentBook;
|
||||
|
||||
// Record vote (fire and forget)
|
||||
fetch('/api/discover/vote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: book.slug, action })
|
||||
});
|
||||
|
||||
// Optimistically add/update the history list so the drawer shows it immediately.
|
||||
// If this slug was already voted (e.g. swiped twice via undo+re-swipe), replace it.
|
||||
const existing = votedBooks.findIndex((v) => v.slug === book.slug);
|
||||
const entry: VotedBook = { slug: book.slug, action, votedAt: new Date().toISOString(), book };
|
||||
if (existing !== -1) {
|
||||
@@ -249,7 +256,6 @@
|
||||
votedBooks = [entry, ...votedBooks];
|
||||
}
|
||||
|
||||
// Fly out
|
||||
transitioning = true;
|
||||
const target = flyTargets[action];
|
||||
offsetX = target.x;
|
||||
@@ -257,7 +263,6 @@
|
||||
|
||||
await delay(360);
|
||||
|
||||
// Advance
|
||||
voted = { slug: book.slug, action };
|
||||
idx++;
|
||||
transitioning = false;
|
||||
@@ -279,6 +284,21 @@
|
||||
idx = 0;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts (desktop) ─────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (showOnboarding || showPreview || showHistory) return;
|
||||
if (animating || !currentBook) return;
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); doAction('like'); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); doAction('skip'); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); doAction('read_now'); }
|
||||
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPreview = true; }
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
|
||||
@@ -290,8 +310,6 @@
|
||||
<h2 class="text-xl font-bold text-(--color-text) mb-1">What do you like to read?</h2>
|
||||
<p class="text-sm text-(--color-muted)">We'll show you books you'll actually enjoy. Skip to see everything.</p>
|
||||
</div>
|
||||
|
||||
<!-- Genre pills -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Genres</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -309,14 +327,10 @@
|
||||
{tempGenres.includes(genre)
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:border-(--color-border) hover:text-(--color-text)'}"
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
>{genre}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Status</p>
|
||||
<div class="flex gap-2">
|
||||
@@ -328,26 +342,17 @@
|
||||
{tempStatus === s
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:text-(--color-text)'}"
|
||||
>
|
||||
{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
>{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<button type="button" onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
<button type="button" onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors">
|
||||
Start Discovering
|
||||
</button>
|
||||
</div>
|
||||
@@ -358,7 +363,7 @@
|
||||
|
||||
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
||||
{#if showPreview && currentBook}
|
||||
{@const previewBook = currentBook!}
|
||||
{@const previewBook = currentBook}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
||||
role="presentation"
|
||||
@@ -368,13 +373,10 @@
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
role="dialog" aria-modal="true" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Cover strip -->
|
||||
<div class="relative h-40 overflow-hidden">
|
||||
{#if previewBook.cover}
|
||||
<img src={previewBook.cover} alt={previewBook.title} class="w-full h-full object-cover object-top" />
|
||||
@@ -387,7 +389,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
|
||||
{#if previewBook.author}
|
||||
@@ -407,29 +408,13 @@
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('skip'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('read_now'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
Read Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('like'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors"
|
||||
>
|
||||
Add ♥
|
||||
</button>
|
||||
<button type="button" onclick={() => { showPreview = false; doAction('skip'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors">Skip</button>
|
||||
<button type="button" onclick={() => { showPreview = false; doAction('read_now'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors">Read Now</button>
|
||||
<button type="button" onclick={() => { showPreview = false; doAction('like'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors">Add ♥</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -447,20 +432,14 @@
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden max-h-[80vh] flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
role="dialog" aria-modal="true" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between p-5 border-b border-(--color-border) flex-shrink-0">
|
||||
<h3 class="font-bold text-(--color-text)">History {#if votedBooks.length}<span class="text-(--color-muted) font-normal text-sm">({votedBooks.length})</span>{/if}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showHistory = false)}
|
||||
aria-label="Close history"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<button type="button" onclick={() => (showHistory = false)} aria-label="Close history"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
@@ -472,7 +451,7 @@
|
||||
{:else}
|
||||
{#each votedBooks as v (v.slug)}
|
||||
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
|
||||
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
|
||||
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : 'Skipped'}
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-xl p-3">
|
||||
{#if v.book?.cover}
|
||||
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
|
||||
@@ -488,24 +467,17 @@
|
||||
{/if}
|
||||
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => undoVote(v.slug)}
|
||||
title="Undo"
|
||||
<button type="button" onclick={() => undoVote(v.slug)} title="Undo"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
|
||||
aria-label="Undo vote for {v.book?.title ?? v.slug}"
|
||||
>
|
||||
aria-label="Undo vote for {v.book?.title ?? v.slug}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
|
||||
>
|
||||
<button type="button" onclick={resetDeck}
|
||||
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2">
|
||||
Clear all history
|
||||
</button>
|
||||
{/if}
|
||||
@@ -514,247 +486,294 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
|
||||
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-3 pt-6 pb-6 select-none">
|
||||
<!-- ── 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]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="w-full max-w-sm flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- History button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showHistory = true)}
|
||||
title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Preferences button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Left: card area ──────────────────────────────────────────────────── -->
|
||||
<div class="relative bg-(--color-surface) flex flex-col items-center justify-center
|
||||
px-4 pt-6 pb-4 lg:px-8 lg:pt-8 lg:pb-8
|
||||
min-h-[calc(100svh-3.5rem)] lg:border-r lg:border-(--color-border)">
|
||||
|
||||
{#if deckEmpty}
|
||||
<!-- Empty state -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">
|
||||
📚
|
||||
</div>
|
||||
<!-- Header row -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}
|
||||
Try adjusting your preferences to see more.
|
||||
{:else}
|
||||
Check your library for books you liked.
|
||||
{/if}
|
||||
</p>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !loading && !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Start over
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" onclick={() => (showHistory = true)} title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const book = currentBook!}
|
||||
|
||||
<!-- Card stack — fills available width, taller ratio -->
|
||||
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.6;">
|
||||
|
||||
<!-- Back card 2 -->
|
||||
{#if nextNextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
|
||||
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextNextBook.cover}
|
||||
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<!-- ── Skeleton ──────────────────────────────────────────────────── -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex-1 flex flex-col gap-4">
|
||||
<div class="flex-1 rounded-2xl bg-(--color-surface-2) animate-pulse" style="min-height: 340px;"></div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
|
||||
<div class="flex-[1.4] h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
|
||||
<div class="flex-1 h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Back card 1 -->
|
||||
{#if nextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
|
||||
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextBook.cover}
|
||||
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
{:else if deckEmpty}
|
||||
<!-- ── Empty state ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">📚</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}Try adjusting your preferences to see more.
|
||||
{:else}Check your library for books you liked.{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<button type="button" onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Start over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active card -->
|
||||
{#if currentBook}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
role="none"
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="
|
||||
transform: {activeTransform};
|
||||
transition: {activeTransition};
|
||||
touch-action: none;
|
||||
"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<!-- Cover image -->
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
{:else}
|
||||
{@const book = currentBook!}
|
||||
|
||||
<!-- ── Card stack ───────────────────────────────────────────────── -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex-1 relative" style="min-height: 300px;">
|
||||
|
||||
<!-- Back card 2 -->
|
||||
{#if nextNextBook}
|
||||
<div class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
|
||||
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;">
|
||||
{#if nextNextBook.cover}
|
||||
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom gradient + info -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
|
||||
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
|
||||
{#if book.author}
|
||||
<p class="text-white/70 text-sm mb-2">{book.author}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
{/if}
|
||||
{#if book.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
|
||||
<!-- Back card 1 -->
|
||||
{#if nextBook}
|
||||
<div class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
|
||||
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;">
|
||||
{#if nextBook.cover}
|
||||
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- LIKE indicator (right swipe) -->
|
||||
<!-- Active card -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
|
||||
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
bind:this={cardEl}
|
||||
role="none"
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="transform: {activeTransform}; transition: {activeTransition}; touch-action: none;"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
|
||||
</div>
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SKIP indicator (left swipe) -->
|
||||
<div
|
||||
class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
|
||||
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
|
||||
</div>
|
||||
<!-- Bottom gradient + title/genres (mobile only — desktop shows in panel) -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none lg:hidden"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none lg:hidden">
|
||||
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
|
||||
{#if book.author}<p class="text-white/70 text-sm mb-2">{book.author}</p>{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
{/if}
|
||||
{#if book.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- READ NOW indicator (swipe up) -->
|
||||
<div
|
||||
class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
|
||||
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
|
||||
</div>
|
||||
|
||||
<!-- NOPE indicator (swipe down) -->
|
||||
<div
|
||||
class="absolute bottom-32 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-zinc-400 pointer-events-none bg-black/20"
|
||||
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-zinc-300 font-black text-2xl tracking-widest">NOPE</span>
|
||||
<!-- Swipe indicators -->
|
||||
<div class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
|
||||
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
|
||||
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
|
||||
</div>
|
||||
<div class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
|
||||
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
|
||||
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
|
||||
</div>
|
||||
<div class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
|
||||
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
|
||||
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons — 3 prominent labeled buttons -->
|
||||
<div class="w-full max-w-sm flex items-stretch gap-3 mt-5">
|
||||
<!-- Skip -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('skip')}
|
||||
disabled={animating}
|
||||
title="Skip"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-red-500/15 border border-red-500/30 text-red-400
|
||||
hover:bg-red-500/25 hover:border-red-500/50
|
||||
active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold tracking-wide">Skip</span>
|
||||
</button>
|
||||
<!-- Action buttons -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex items-stretch gap-3 mt-4 shrink-0">
|
||||
<button type="button" onclick={() => doAction('skip')} disabled={animating} title="Skip (←)"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-red-500/15 border border-red-500/30 text-red-400
|
||||
hover:bg-red-500/25 hover:border-red-500/50 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold tracking-wide">Skip</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => doAction('read_now')} disabled={animating} title="Read Now (↑)"
|
||||
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-blue-500 text-white hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
<span class="text-xs font-bold tracking-wide">Read Now</span>
|
||||
</button>
|
||||
<button type="button" onclick={() => doAction('like')} disabled={animating} title="Like (→)"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-green-500/15 border border-green-500/30 text-green-400
|
||||
hover:bg-green-500/25 hover:border-green-500/50 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold tracking-wide">Like</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Read Now — center, most prominent -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('read_now')}
|
||||
disabled={animating}
|
||||
title="Read Now"
|
||||
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-blue-500 text-white
|
||||
hover:bg-blue-400
|
||||
active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold tracking-wide">Read Now</span>
|
||||
</button>
|
||||
<!-- Keyboard hint (desktop only) -->
|
||||
<p class="hidden lg:block text-xs text-(--color-muted)/40 mt-2 shrink-0">← Skip · ↑ Read now · → Like · Space for details</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Right: book details panel (desktop only) ──────────────────────────── -->
|
||||
<div class="hidden lg:flex flex-col bg-(--color-surface-2) border-l border-(--color-border)
|
||||
overflow-y-auto" style="max-height: calc(100svh - 3.5rem);">
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton -->
|
||||
<div class="p-8 flex flex-col gap-4">
|
||||
<div class="h-6 rounded-lg bg-(--color-surface-3) animate-pulse w-3/4"></div>
|
||||
<div class="h-4 rounded-lg bg-(--color-surface-3) animate-pulse w-1/2"></div>
|
||||
<div class="h-32 rounded-xl bg-(--color-surface-3) animate-pulse"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-16"></div>
|
||||
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-20"></div>
|
||||
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-14"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !deckEmpty && currentBook}
|
||||
{@const book = currentBook}
|
||||
<!-- Book details -->
|
||||
<div class="p-8 flex flex-col gap-6 flex-1">
|
||||
<!-- Title + author -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">Now showing</p>
|
||||
<h2 class="text-2xl font-bold text-(--color-text) leading-snug mb-1">{book.title}</h2>
|
||||
{#if book.author}
|
||||
<p class="text-sm text-(--color-muted)">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata pills -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each parseBookGenres(book.genres).slice(0, 5) as genre}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-text) font-medium">{book.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex items-center gap-4 text-sm text-(--color-muted)">
|
||||
{#if book.total_chapters}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{book.total_chapters} chapters
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted)/40">{idx + 1} of {deck.length}</span>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
{#if book.summary}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Summary</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">{book.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons (duplicated for desktop convenience) -->
|
||||
<div class="mt-auto flex flex-col gap-2.5 pt-4 border-t border-(--color-border)">
|
||||
<button type="button" onclick={() => doAction('read_now')} disabled={animating}
|
||||
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-blue-500 text-white font-bold text-sm hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/20">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Read Now
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => doAction('skip')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-red-500/15 border border-red-500/30 text-red-400 text-sm font-semibold hover:bg-red-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Skip
|
||||
</button>
|
||||
<button type="button" onclick={() => doAction('like')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-green-500/15 border border-green-500/30 text-green-400 text-sm font-semibold hover:bg-green-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
Like
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick={() => { showPreview = true; }}
|
||||
class="w-full py-2 rounded-xl text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
|
||||
More details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if deckEmpty}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-center p-8 text-(--color-muted)">
|
||||
<span class="text-4xl">📚</span>
|
||||
<p class="text-sm">All caught up!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Like -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('like')}
|
||||
disabled={animating}
|
||||
title="Add to Library"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
|
||||
bg-green-500/15 border border-green-500/30 text-green-400
|
||||
hover:bg-green-500/25 hover:border-green-500/50
|
||||
active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold tracking-wide">Like</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user