Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bb19892c | ||
|
|
6ca704ec9a | ||
|
|
2bdb5e29af | ||
|
|
222627a18c | ||
|
|
0ae71c62f9 | ||
|
|
d0c95889ca | ||
|
|
a3ad54db70 | ||
|
|
48bc206c4e | ||
|
|
4c1ad84fa9 | ||
|
|
9c79fd5deb | ||
|
|
7aad42834f | ||
|
|
15a31a5c64 | ||
|
|
4d3b91af30 | ||
|
|
eb8a92f0c1 |
@@ -55,6 +55,13 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: ui/build
|
||||
retention-days: 1
|
||||
|
||||
# ── docker: backend ───────────────────────────────────────────────────────────
|
||||
docker-backend:
|
||||
name: Docker / backend
|
||||
@@ -140,45 +147,76 @@ jobs:
|
||||
name: Upload source maps
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build with source maps
|
||||
run: npm run build
|
||||
|
||||
- name: Download glitchtip-cli
|
||||
- name: Compute release version (strip leading v)
|
||||
id: ver
|
||||
run: |
|
||||
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||
-o /usr/local/bin/glitchtip-cli
|
||||
chmod +x /usr/local/bin/glitchtip-cli
|
||||
V="${{ gitea.ref_name }}"
|
||||
echo "version=${V#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: build
|
||||
|
||||
- name: Install sentry-cli
|
||||
run: npm install -g @sentry/cli
|
||||
|
||||
- name: Inject debug IDs into build artifacts
|
||||
run: glitchtip-cli sourcemaps inject ./build
|
||||
run: sentry-cli sourcemaps inject ./build
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Create GlitchTip release
|
||||
run: sentry-cli releases new ${{ steps.ver.outputs.version }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Upload source maps to GlitchTip
|
||||
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Finalize GlitchTip release
|
||||
run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Prune old GlitchTip releases (keep latest 10)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEEP=10
|
||||
OLD=$(curl -sf \
|
||||
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
|
||||
"$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
releases = json.load(sys.stdin)
|
||||
for r in releases[$KEEP:]:
|
||||
print(r['version'])
|
||||
" KEEP=$KEEP)
|
||||
for ver in $OLD; do
|
||||
echo "Deleting old release: $ver"
|
||||
sentry-cli releases delete "$ver" || true
|
||||
done
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||
docker-ui:
|
||||
|
||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# LibNovel v2 — Agent Context
|
||||
|
||||
This file is the root-level knowledge base for LLM coding agents (OpenCode, Claude, Cursor, Copilot, etc.).
|
||||
Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS.md`).
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| UI | SvelteKit 2 + Svelte 5, TypeScript, TailwindCSS |
|
||||
| Backend / Runner | Go (single repo, two binaries: `backend`, `runner`) |
|
||||
| iOS app | SwiftUI, iOS 17+, Swift 5.9+ |
|
||||
| Database | PocketBase (SQLite) + MinIO (object storage) |
|
||||
| Search | Meilisearch |
|
||||
| Queue | Asynq over Redis (local) / Valkey (prod) |
|
||||
| Scraping | Novelfire scraper in `backend/novelfire/` |
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── .gitea/workflows/ # CI/CD — Gitea Actions (NOT .github/)
|
||||
├── .opencode/ # OpenCode agent config (memory, skills)
|
||||
├── backend/ # Go backend + runner (single module)
|
||||
├── caddy/ # Caddy reverse proxy Dockerfile
|
||||
├── homelab/ # Homelab docker-compose + observability stack
|
||||
├── ios/ # SwiftUI iOS app (see ios/AGENTS.md)
|
||||
├── scripts/ # Utility scripts
|
||||
├── ui/ # SvelteKit UI
|
||||
├── docker-compose.yml # Prod compose (all services)
|
||||
├── AGENTS.md # This file
|
||||
└── opencode.json # OpenCode config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD — Gitea Actions
|
||||
|
||||
- Workflows live in `.gitea/workflows/` — **not** `.github/workflows/`
|
||||
- Self-hosted Gitea instance; use `gitea.ref_name` / `gitea.sha` (not `github.*`)
|
||||
- Two workflows:
|
||||
- `ci.yaml` — runs on every push to `main` (test + type-check)
|
||||
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
|
||||
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
|
||||
|
||||
### Releasing a new version
|
||||
|
||||
```bash
|
||||
git tag v2.5.X -m "Short title\n\nOptional longer body"
|
||||
git push origin v2.5.X
|
||||
```
|
||||
|
||||
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
|
||||
|
||||
---
|
||||
|
||||
## GlitchTip Error Tracking
|
||||
|
||||
- Instance: `https://errors.libnovel.cc/`
|
||||
- Org: `libnovel`
|
||||
- Projects: `ui` (id/1), `backend` (id/2), `runner` (id/3)
|
||||
- Tool: `glitchtip-cli` v0.1.0
|
||||
|
||||
### Per-service DSNs (stored in Doppler)
|
||||
|
||||
| Service | Doppler key | GlitchTip project |
|
||||
|---|---|---|
|
||||
| UI (SvelteKit) | `PUBLIC_GLITCHTIP_DSN` | ui (1) |
|
||||
| Backend (Go) | `GLITCHTIP_DSN_BACKEND` | backend (2) |
|
||||
| Runner (Go) | `GLITCHTIP_DSN_RUNNER` | runner (3) |
|
||||
|
||||
### Source map upload flow (release.yaml)
|
||||
|
||||
The correct order is **critical** — uploading before `releases new` results in 0 files shown in GlitchTip UI:
|
||||
|
||||
```
|
||||
glitchtip-cli sourcemaps inject ./build # inject debug IDs
|
||||
glitchtip-cli releases new <version> # MUST come before upload
|
||||
glitchtip-cli sourcemaps upload ./build \
|
||||
--release <version> # associate files with release
|
||||
glitchtip-cli releases finalize <version> # mark release complete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Environment | Host | Path | Doppler config |
|
||||
|---|---|---|---|
|
||||
| Prod | `165.22.70.138` | `/opt/libnovel/` | `prd` |
|
||||
| Homelab runner | `192.168.0.109` | `/opt/libnovel-runner/` | `prd_homelab` |
|
||||
|
||||
### Docker Compose — always use Doppler
|
||||
|
||||
```bash
|
||||
# Prod
|
||||
doppler run --project libnovel --config prd -- docker compose <cmd>
|
||||
|
||||
# Homelab full-stack (runs from .bak file on server)
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/docker-compose.yml.bak <cmd>
|
||||
|
||||
# Homelab runner only
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/runner/docker-compose.yml <cmd>
|
||||
```
|
||||
|
||||
- Prod runner has `profiles: [runner]` — `docker compose up -d` will NOT accidentally start it
|
||||
- When deploying, always sync `docker-compose.yml` to the server before running `up -d`
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| GlitchTip | Error tracking (UI + backend + runner) |
|
||||
| Grafana Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) |
|
||||
| OpenTelemetry | Distributed tracing (OTLP → collector → Tempo) |
|
||||
| Grafana | Dashboards at `/admin/grafana` |
|
||||
|
||||
Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/`
|
||||
|
||||
---
|
||||
|
||||
## Go Backend
|
||||
|
||||
- Primary files: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go`
|
||||
- Store interface: `store.go` — never touch MinIO/PocketBase clients directly outside `storage/`
|
||||
- Two binaries built from the same module: `backend` (HTTP API) and `runner` (Asynq worker)
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit UI
|
||||
|
||||
- Source: `ui/src/`
|
||||
- i18n: Paraglide — translation files in `ui/messages/*.json` (5 locales)
|
||||
- Auth debug bypass: `GET /api/auth/debug-login?token=<DEBUG_LOGIN_TOKEN>&username=<username>&next=<path>`
|
||||
|
||||
---
|
||||
|
||||
## iOS App
|
||||
|
||||
Full context in `ios/AGENTS.md`. Quick notes:
|
||||
- SwiftUI, iOS 17+, `@Observable` for new types
|
||||
- Download key separator: `::` (not `-`)
|
||||
- Voice fallback: book override → global default → `"af_bella"`
|
||||
- Offline pattern: `NetworkMonitor` env object + `OfflineBanner` + `ErrorAlertModifier`
|
||||
@@ -195,6 +195,7 @@ func run() error {
|
||||
ImageGen: imageGenClient,
|
||||
TextGen: textGenClient,
|
||||
BookWriter: store,
|
||||
AIJobStore: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
233
backend/internal/backend/handlers_aijobs.go
Normal file
233
backend/internal/backend/handlers_aijobs.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── Cancel registry ────────────────────────────────────────────────────────────
|
||||
// cancelJobsMu guards cancelJobs.
|
||||
var cancelJobsMu sync.Mutex
|
||||
|
||||
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
|
||||
// job starts and removed when it finishes or is cancelled.
|
||||
var cancelJobs = map[string]context.CancelFunc{}
|
||||
|
||||
func registerCancelJob(id string, cancel context.CancelFunc) {
|
||||
cancelJobsMu.Lock()
|
||||
cancelJobs[id] = cancel
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func deregisterCancelJob(id string) {
|
||||
cancelJobsMu.Lock()
|
||||
delete(cancelJobs, id)
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
// ── AI Job list / get / cancel ─────────────────────────────────────────────────
|
||||
|
||||
// handleAdminListAIJobs handles GET /api/admin/ai-jobs.
|
||||
// Returns all ai_job records sorted by started descending.
|
||||
func (s *Server) handleAdminListAIJobs(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.AIJobStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||
return
|
||||
}
|
||||
jobs, err := s.deps.AIJobStore.ListAIJobs(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("admin: list ai jobs failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "list ai jobs: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// handleAdminGetAIJob handles GET /api/admin/ai-jobs/{id}.
|
||||
func (s *Server) handleAdminGetAIJob(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.AIJobStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
job, ok, err := s.deps.AIJobStore.GetAIJob(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", id))
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, job)
|
||||
}
|
||||
|
||||
// handleAdminCancelAIJob handles POST /api/admin/ai-jobs/{id}/cancel.
|
||||
// Marks the job as cancelled in PB and cancels the in-memory context if present.
|
||||
func (s *Server) handleAdminCancelAIJob(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.AIJobStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
|
||||
// Cancel in-memory context if the job is still running in this process.
|
||||
cancelJobsMu.Lock()
|
||||
if cancel, ok := cancelJobs[id]; ok {
|
||||
cancel()
|
||||
}
|
||||
cancelJobsMu.Unlock()
|
||||
|
||||
// Mark as cancelled in PB.
|
||||
if err := s.deps.AIJobStore.UpdateAIJob(r.Context(), id, map[string]any{
|
||||
"status": string(domain.TaskStatusCancelled),
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
s.deps.Log.Error("admin: cancel ai job failed", "id", id, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "cancel ai job: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: ai job cancelled", "id", id)
|
||||
writeJSON(w, 0, map[string]any{"cancelled": true})
|
||||
}
|
||||
|
||||
// ── Auto-prompt ────────────────────────────────────────────────────────────────
|
||||
|
||||
// autoPromptRequest is the JSON body for POST /api/admin/image-gen/auto-prompt.
|
||||
type autoPromptRequest struct {
|
||||
// Slug is the book slug.
|
||||
Slug string `json:"slug"`
|
||||
// Type is "cover" or "chapter".
|
||||
Type string `json:"type"`
|
||||
// Chapter number (required when type == "chapter").
|
||||
Chapter int `json:"chapter"`
|
||||
// Model is the text-gen model to use. Defaults to DefaultTextModel.
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// autoPromptResponse is returned by POST /api/admin/image-gen/auto-prompt.
|
||||
type autoPromptResponse struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminImageGenAutoPrompt handles POST /api/admin/image-gen/auto-prompt.
|
||||
//
|
||||
// Uses the text generation model to create a vivid image generation prompt
|
||||
// based on the book's description (for covers) or chapter title/content (for chapters).
|
||||
func (s *Server) handleAdminImageGenAutoPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req autoPromptRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if req.Type != "cover" && req.Type != "chapter" {
|
||||
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = string(cfai.DefaultTextModel)
|
||||
}
|
||||
|
||||
var userPrompt string
|
||||
if req.Type == "cover" {
|
||||
userPrompt = fmt.Sprintf(
|
||||
"Book: \"%s\"\nAuthor: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
meta.Author,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
} else {
|
||||
// For chapter images, use chapter title if available.
|
||||
chapterTitle := fmt.Sprintf("Chapter %d", req.Chapter)
|
||||
if req.Chapter > 0 {
|
||||
chapters, listErr := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||
if listErr == nil {
|
||||
for _, ch := range chapters {
|
||||
if ch.Number == req.Chapter {
|
||||
chapterTitle = ch.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
userPrompt = fmt.Sprintf(
|
||||
"Book: \"%s\"\nGenres: %s\nChapter: %s\n\nBook description:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
chapterTitle,
|
||||
meta.Summary,
|
||||
)
|
||||
}
|
||||
|
||||
systemPrompt := buildAutoPromptSystem(req.Type)
|
||||
|
||||
s.deps.Log.Info("admin: image auto-prompt requested",
|
||||
"slug", req.Slug, "type", req.Type, "chapter", req.Chapter, "model", model)
|
||||
|
||||
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: cfai.TextModel(model),
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: 256,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: auto-prompt failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, autoPromptResponse{
|
||||
Prompt: strings.TrimSpace(result),
|
||||
Model: model,
|
||||
})
|
||||
}
|
||||
|
||||
func buildAutoPromptSystem(imageType string) string {
|
||||
if imageType == "cover" {
|
||||
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
|
||||
`Given a book's title, genres, and description, write a single vivid image generation prompt ` +
|
||||
`for a book cover. The prompt should describe the visual composition, art style, lighting, ` +
|
||||
`and mood without mentioning text or typography. ` +
|
||||
`Format: comma-separated visual descriptors, 30–60 words. ` +
|
||||
`Output ONLY the prompt — no explanation, no quotes, no labels.`
|
||||
}
|
||||
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
|
||||
`Given a book's title, genres, and a specific chapter title, write a single vivid scene illustration prompt. ` +
|
||||
`Describe the scene, characters, setting, lighting, and art style. ` +
|
||||
`Format: comma-separated visual descriptors, 30–60 words. ` +
|
||||
`Output ONLY the prompt — no explanation, no quotes, no labels.`
|
||||
}
|
||||
@@ -16,32 +16,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── Cancel registry ────────────────────────────────────────────────────────
|
||||
|
||||
// cancelJobsMu guards cancelJobs.
|
||||
var cancelJobsMu sync.Mutex
|
||||
|
||||
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
|
||||
// job starts and removed when it finishes or is cancelled.
|
||||
var cancelJobs = map[string]context.CancelFunc{}
|
||||
|
||||
func registerCancelJob(id string, cancel context.CancelFunc) {
|
||||
cancelJobsMu.Lock()
|
||||
cancelJobs[id] = cancel
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func deregisterCancelJob(id string) {
|
||||
cancelJobsMu.Lock()
|
||||
delete(cancelJobs, id)
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
// ── Tagline ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -452,8 +432,9 @@ type batchCoverEvent struct {
|
||||
// Streams SSE events as it generates covers for every book that has no cover
|
||||
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
|
||||
//
|
||||
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
|
||||
// with body {"job_id":"..."}.
|
||||
// Supports from_item/to_item to process a sub-range of the catalogue (0-based indices).
|
||||
// Supports job_id to resume a previously interrupted job.
|
||||
// The job can be cancelled by calling POST /api/admin/ai-jobs/{id}/cancel.
|
||||
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
|
||||
@@ -469,22 +450,34 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
|
||||
NumSteps int `json:"num_steps"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FromItem int `json:"from_item"`
|
||||
ToItem int `json:"to_item"`
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
// Body is optional — defaults used if absent.
|
||||
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
|
||||
|
||||
books, err := s.deps.BookReader.ListBooks(r.Context())
|
||||
allBooks, err := s.deps.BookReader.ListBooks(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a unique job ID.
|
||||
jobID := randomHex(8)
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
registerCancelJob(jobID, cancel)
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer cancel()
|
||||
// Apply range filter.
|
||||
books := allBooks
|
||||
if reqBody.FromItem > 0 || reqBody.ToItem > 0 {
|
||||
from := reqBody.FromItem
|
||||
to := reqBody.ToItem
|
||||
if to == 0 || to >= len(allBooks) {
|
||||
to = len(allBooks) - 1
|
||||
}
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if from <= to && from < len(allBooks) {
|
||||
books = allBooks[from : to+1]
|
||||
}
|
||||
}
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
@@ -503,19 +496,75 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
|
||||
total := len(books)
|
||||
done := 0
|
||||
|
||||
// Send initial event with jobID so frontend can store it for cancellation.
|
||||
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
|
||||
// Create or resume PB ai_job and register cancel context.
|
||||
var pbJobID string
|
||||
resumeFrom := 0
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
for _, book := range books {
|
||||
if s.deps.AIJobStore != nil {
|
||||
if reqBody.JobID != "" {
|
||||
if existing, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), reqBody.JobID); ok {
|
||||
pbJobID = reqBody.JobID
|
||||
resumeFrom = existing.ItemsDone
|
||||
done = resumeFrom
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
|
||||
"status": string(domain.TaskStatusRunning),
|
||||
"items_total": total,
|
||||
})
|
||||
}
|
||||
}
|
||||
if pbJobID == "" {
|
||||
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
|
||||
Kind: "batch-covers",
|
||||
Status: domain.TaskStatusRunning,
|
||||
FromItem: reqBody.FromItem,
|
||||
ToItem: reqBody.ToItem,
|
||||
ItemsTotal: total,
|
||||
Started: time.Now(),
|
||||
})
|
||||
if createErr == nil {
|
||||
pbJobID = id
|
||||
}
|
||||
}
|
||||
if pbJobID != "" {
|
||||
registerCancelJob(pbJobID, cancel)
|
||||
defer deregisterCancelJob(pbJobID)
|
||||
}
|
||||
}
|
||||
|
||||
// Use pbJobID as the SSE job_id when available, else a random hex fallback.
|
||||
sseJobID := pbJobID
|
||||
if sseJobID == "" {
|
||||
sseJobID = randomHex(8)
|
||||
ctx2, cancel2 := context.WithCancel(r.Context())
|
||||
registerCancelJob(sseJobID, cancel2)
|
||||
defer deregisterCancelJob(sseJobID)
|
||||
defer cancel2()
|
||||
cancel() // replace ctx with ctx2
|
||||
ctx = ctx2
|
||||
}
|
||||
|
||||
// Send initial event with jobID so frontend can store it for cancellation.
|
||||
sseWrite(batchCoverEvent{JobID: sseJobID, Done: done, Total: total})
|
||||
|
||||
for i, book := range books {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// Skip already-processed items when resuming.
|
||||
if i < resumeFrom {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if cover already exists.
|
||||
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
|
||||
if hasCover {
|
||||
done++
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
|
||||
if pbJobID != "" && s.deps.AIJobStore != nil {
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -547,6 +596,21 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
|
||||
done++
|
||||
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
|
||||
if pbJobID != "" && s.deps.AIJobStore != nil {
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
|
||||
}
|
||||
}
|
||||
|
||||
if pbJobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if ctx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": done,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
@@ -38,6 +40,13 @@ type textGenChapterNamesRequest struct {
|
||||
Model string `json:"model"`
|
||||
// MaxTokens limits response length (0 = model default).
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
// FromChapter is the first chapter to process (1-based). 0 = start from chapter 1.
|
||||
FromChapter int `json:"from_chapter"`
|
||||
// ToChapter is the last chapter to process (inclusive). 0 = process all.
|
||||
ToChapter int `json:"to_chapter"`
|
||||
// JobID is an optional existing ai_job ID for resuming a previous run.
|
||||
// If set, the handler resumes from items_done instead of starting from scratch.
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
@@ -51,6 +60,8 @@ type proposedChapterTitle struct {
|
||||
|
||||
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
|
||||
type chapterNamesBatchEvent struct {
|
||||
// JobID is the PB ai_job ID for this run (emitted on the first event only).
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
// Batch is the 1-based batch index.
|
||||
Batch int `json:"batch"`
|
||||
// TotalBatches is the total number of batches.
|
||||
@@ -99,16 +110,36 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// Load existing chapter list.
|
||||
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
if len(allChapters) == 0 {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
// Apply chapter range filter.
|
||||
chapters := allChapters
|
||||
if req.FromChapter > 0 || req.ToChapter > 0 {
|
||||
filtered := chapters[:0]
|
||||
for _, ch := range allChapters {
|
||||
if req.FromChapter > 0 && ch.Number < req.FromChapter {
|
||||
continue
|
||||
}
|
||||
if req.ToChapter > 0 && ch.Number > req.ToChapter {
|
||||
break
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
chapters = filtered
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
@@ -160,10 +191,58 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
chaptersDone := 0
|
||||
// Create or resume an ai_job record for tracking.
|
||||
var jobID string
|
||||
resumeFrom := 0
|
||||
jobCtx := r.Context()
|
||||
var jobCancel context.CancelFunc
|
||||
|
||||
if s.deps.AIJobStore != nil {
|
||||
if req.JobID != "" {
|
||||
if existingJob, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), req.JobID); ok {
|
||||
jobID = req.JobID
|
||||
resumeFrom = existingJob.ItemsDone
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
"status": string(domain.TaskStatusRunning),
|
||||
"items_total": len(chapters),
|
||||
})
|
||||
}
|
||||
}
|
||||
if jobID == "" {
|
||||
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
|
||||
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
|
||||
Kind: "chapter-names",
|
||||
Slug: req.Slug,
|
||||
Status: domain.TaskStatusRunning,
|
||||
FromItem: req.FromChapter,
|
||||
ToItem: req.ToChapter,
|
||||
ItemsTotal: len(chapters),
|
||||
Model: string(model),
|
||||
Payload: jobPayload,
|
||||
Started: time.Now(),
|
||||
})
|
||||
if createErr == nil {
|
||||
jobID = id
|
||||
}
|
||||
}
|
||||
if jobID != "" {
|
||||
jobCtx, jobCancel = context.WithCancel(r.Context())
|
||||
registerCancelJob(jobID, jobCancel)
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer jobCancel()
|
||||
}
|
||||
}
|
||||
|
||||
chaptersDone := resumeFrom
|
||||
firstEvent := true
|
||||
for i, batch := range batches {
|
||||
if r.Context().Err() != nil {
|
||||
return // client disconnected
|
||||
if jobCtx.Err() != nil {
|
||||
return // client disconnected or cancelled
|
||||
}
|
||||
// Skip batches already processed in a previous run.
|
||||
batchEnd := (i + 1) * chapterNamesBatchSize
|
||||
if batchEnd <= resumeFrom {
|
||||
continue
|
||||
}
|
||||
|
||||
var chapterListSB strings.Builder
|
||||
@@ -172,7 +251,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
raw, genErr := s.deps.TextGen.Generate(jobCtx, cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
@@ -183,14 +262,19 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
|
||||
"batch", i+1, "err", genErr)
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
evt := chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Error: genErr.Error(),
|
||||
})
|
||||
}
|
||||
if firstEvent {
|
||||
evt.JobID = jobID
|
||||
firstEvent = false
|
||||
}
|
||||
sseWrite(evt)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -205,13 +289,37 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
chaptersDone += len(batch)
|
||||
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
"items_done": chaptersDone,
|
||||
})
|
||||
}
|
||||
|
||||
evt := chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Chapters: result,
|
||||
}
|
||||
if firstEvent {
|
||||
evt.JobID = jobID
|
||||
firstEvent = false
|
||||
}
|
||||
sseWrite(evt)
|
||||
}
|
||||
|
||||
// Mark job as done in PB.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ type Dependencies struct {
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// AIJobStore tracks long-running AI generation jobs in PocketBase.
|
||||
// If nil, job persistence is disabled (jobs still run but are not recorded).
|
||||
AIJobStore bookstore.AIJobStore
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -214,6 +217,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
mux.HandleFunc("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)
|
||||
|
||||
// Auto-prompt generation from book/chapter content
|
||||
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
|
||||
@@ -158,6 +158,19 @@ type CoverStore interface {
|
||||
CoverExists(ctx context.Context, slug string) bool
|
||||
}
|
||||
|
||||
// AIJobStore manages AI generation jobs tracked in PocketBase.
|
||||
type AIJobStore interface {
|
||||
// CreateAIJob inserts a new ai_job record with status=running and returns its ID.
|
||||
CreateAIJob(ctx context.Context, job domain.AIJob) (string, error)
|
||||
// GetAIJob retrieves a single ai_job by ID.
|
||||
// Returns (zero, false, nil) when not found.
|
||||
GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error)
|
||||
// UpdateAIJob patches an existing ai_job record with the given fields.
|
||||
UpdateAIJob(ctx context.Context, id string, fields map[string]any) error
|
||||
// ListAIJobs returns all ai_job records sorted by started descending.
|
||||
ListAIJobs(ctx context.Context) ([]domain.AIJob, error)
|
||||
}
|
||||
|
||||
// TranslationStore covers machine-translated chapter storage in MinIO.
|
||||
// The runner writes translations; the backend reads them.
|
||||
type TranslationStore interface {
|
||||
|
||||
@@ -169,3 +169,30 @@ type TranslationResult struct {
|
||||
ObjectKey string `json:"object_key,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
|
||||
type AIJob struct {
|
||||
ID string `json:"id"`
|
||||
// Kind is one of: "chapter-names", "batch-covers", "chapter-covers", "refresh-metadata".
|
||||
Kind string `json:"kind"`
|
||||
// Slug is the book slug for per-book jobs; empty for catalogue-wide jobs.
|
||||
Slug string `json:"slug"`
|
||||
Status TaskStatus `json:"status"`
|
||||
// FromItem is the first item to process (chapter number, or 0-based book index).
|
||||
// 0 = start from the beginning.
|
||||
FromItem int `json:"from_item"`
|
||||
// ToItem is the last item to process (inclusive). 0 = process all.
|
||||
ToItem int `json:"to_item"`
|
||||
// ItemsDone is the cumulative count of successfully processed items.
|
||||
ItemsDone int `json:"items_done"`
|
||||
// ItemsTotal is the total number of items in this job.
|
||||
ItemsTotal int `json:"items_total"`
|
||||
Model string `json:"model"`
|
||||
// Payload is a JSON-encoded string with job-specific parameters
|
||||
// (e.g. naming pattern for chapter-names, num_steps for batch-covers).
|
||||
Payload string `json:"payload"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
HeartbeatAt time.Time `json:"heartbeat_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ var _ bookstore.PresignStore = (*Store)(nil)
|
||||
var _ bookstore.ProgressStore = (*Store)(nil)
|
||||
var _ bookstore.CoverStore = (*Store)(nil)
|
||||
var _ bookstore.TranslationStore = (*Store)(nil)
|
||||
var _ bookstore.AIJobStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
@@ -1063,3 +1064,107 @@ func (s *Store) GetTranslation(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ── AIJobStore ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) {
|
||||
payload := map[string]any{
|
||||
"kind": job.Kind,
|
||||
"slug": job.Slug,
|
||||
"status": string(job.Status),
|
||||
"from_item": job.FromItem,
|
||||
"to_item": job.ToItem,
|
||||
"items_done": job.ItemsDone,
|
||||
"items_total": job.ItemsTotal,
|
||||
"model": job.Model,
|
||||
"payload": job.Payload,
|
||||
"started": job.Started.Format(time.RFC3339),
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/ai_jobs/records", payload, &out); err != nil {
|
||||
return "", fmt.Errorf("CreateAIJob: %w", err)
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) {
|
||||
var raw json.RawMessage
|
||||
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), &raw); err != nil {
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
return domain.AIJob{}, false, nil
|
||||
}
|
||||
return domain.AIJob{}, false, fmt.Errorf("GetAIJob: %w", err)
|
||||
}
|
||||
job, err := parseAIJob(raw)
|
||||
if err != nil {
|
||||
return domain.AIJob{}, false, err
|
||||
}
|
||||
return job, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAIJob(ctx context.Context, id string, fields map[string]any) error {
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), fields)
|
||||
}
|
||||
|
||||
func (s *Store) ListAIJobs(ctx context.Context) ([]domain.AIJob, error) {
|
||||
items, err := s.pb.listAll(ctx, "ai_jobs", "", "-started")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListAIJobs: %w", err)
|
||||
}
|
||||
out := make([]domain.AIJob, 0, len(items))
|
||||
for _, raw := range items {
|
||||
j, err := parseAIJob(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
FromItem int `json:"from_item"`
|
||||
ToItem int `json:"to_item"`
|
||||
ItemsDone int `json:"items_done"`
|
||||
ItemsTotal int `json:"items_total"`
|
||||
Model string `json:"model"`
|
||||
Payload string `json:"payload"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
HeartbeatAt string `json:"heartbeat_at"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return domain.AIJob{}, fmt.Errorf("parseAIJob: %w", err)
|
||||
}
|
||||
parseT := func(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, s)
|
||||
return t
|
||||
}
|
||||
return domain.AIJob{
|
||||
ID: r.ID,
|
||||
Kind: r.Kind,
|
||||
Slug: r.Slug,
|
||||
Status: domain.TaskStatus(r.Status),
|
||||
FromItem: r.FromItem,
|
||||
ToItem: r.ToItem,
|
||||
ItemsDone: r.ItemsDone,
|
||||
ItemsTotal: r.ItemsTotal,
|
||||
Model: r.Model,
|
||||
Payload: r.Payload,
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
Started: parseT(r.Started),
|
||||
Finished: parseT(r.Finished),
|
||||
HeartbeatAt: parseT(r.HeartbeatAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -302,6 +302,8 @@ services:
|
||||
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
||||
# GlitchTip client + server-side error tracking
|
||||
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
||||
# Grafana Faro RUM (browser Web Vitals, traces, errors)
|
||||
PUBLIC_FARO_COLLECTOR_URL: "${PUBLIC_FARO_COLLECTOR_URL}"
|
||||
# OpenTelemetry tracing
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "ui"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
# uptime.libnovel.cc → uptime-kuma:3001
|
||||
# push.libnovel.cc → gotify:80
|
||||
# grafana.libnovel.cc → grafana:3000
|
||||
# faro.libnovel.cc → alloy:12347
|
||||
|
||||
services:
|
||||
|
||||
@@ -168,6 +169,11 @@ services:
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
PORT: "8000"
|
||||
ENABLE_USER_REGISTRATION: "false"
|
||||
MEDIA_ROOT: "/code/uploads"
|
||||
volumes:
|
||||
- glitchtip_uploads:/code/uploads
|
||||
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
|
||||
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||
interval: 15s
|
||||
@@ -189,6 +195,11 @@ services:
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
SERVER_ROLE: "worker"
|
||||
MEDIA_ROOT: "/code/uploads"
|
||||
volumes:
|
||||
- glitchtip_uploads:/code/uploads
|
||||
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
|
||||
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
|
||||
|
||||
# ── Umami ───────────────────────────────────────────────────────────────────
|
||||
umami:
|
||||
@@ -346,6 +357,23 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Grafana Alloy (Faro RUM receiver) ───────────────────────────────────────
|
||||
# Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces,
|
||||
# errors). Exposes POST /collect at faro.libnovel.cc via cloudflared.
|
||||
# Forwards traces to otel-collector (→ Tempo) and logs to Loki directly.
|
||||
alloy:
|
||||
image: grafana/alloy:latest
|
||||
restart: unless-stopped
|
||||
command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"]
|
||||
volumes:
|
||||
- ./otel/alloy.river:/etc/alloy/alloy.river:ro
|
||||
expose:
|
||||
- "12347" # Faro HTTP receiver (POST /collect)
|
||||
- "12348" # Alloy UI / health endpoint
|
||||
depends_on:
|
||||
- otel-collector
|
||||
- loki
|
||||
|
||||
# ── OTel Collector ──────────────────────────────────────────────────────────
|
||||
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
|
||||
otel-collector:
|
||||
@@ -522,3 +550,4 @@ volumes:
|
||||
grafana_data:
|
||||
pocket_tts_cache:
|
||||
hf_cache:
|
||||
glitchtip_uploads:
|
||||
|
||||
127
homelab/glitchtip/files_api.py
Normal file
127
homelab/glitchtip/files_api.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Port of sentry.api.endpoints.chunk.ChunkUploadEndpoint"""
|
||||
|
||||
import logging
|
||||
from gzip import GzipFile
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import aget_object_or_404
|
||||
from django.urls import reverse
|
||||
from ninja import File, Router
|
||||
from ninja.errors import HttpError
|
||||
from ninja.files import UploadedFile
|
||||
|
||||
from apps.organizations_ext.models import Organization
|
||||
from glitchtip.api.authentication import AuthHttpRequest
|
||||
from glitchtip.api.decorators import optional_slash
|
||||
from glitchtip.api.permissions import has_permission
|
||||
|
||||
from .models import FileBlob
|
||||
|
||||
# Force just one blob
|
||||
CHUNK_UPLOAD_BLOB_SIZE = 32 * 1024 * 1024 # 32MB
|
||||
MAX_CHUNKS_PER_REQUEST = 1
|
||||
MAX_REQUEST_SIZE = CHUNK_UPLOAD_BLOB_SIZE
|
||||
MAX_CONCURRENCY = 1
|
||||
HASH_ALGORITHM = "sha1"
|
||||
|
||||
CHUNK_UPLOAD_ACCEPT = (
|
||||
"debug_files", # DIF assemble
|
||||
"release_files", # Release files assemble
|
||||
"pdbs", # PDB upload and debug id override
|
||||
"sources", # Source artifact bundle upload
|
||||
"artifact_bundles", # Artifact bundles contain debug ids to link source to sourcemaps
|
||||
"proguard",
|
||||
)
|
||||
|
||||
|
||||
class GzipChunk(BytesIO):
|
||||
def __init__(self, file):
|
||||
raw = file.read()
|
||||
try:
|
||||
data = GzipFile(fileobj=BytesIO(raw), mode="rb").read()
|
||||
except Exception:
|
||||
# sentry-cli 3.x sends raw (uncompressed) zip data despite gzip being
|
||||
# advertised by the server — fall back to using the raw bytes as-is.
|
||||
data = raw
|
||||
self.size = len(data)
|
||||
self.name = file.name
|
||||
super().__init__(data)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@optional_slash(router, "get", "organizations/{slug:organization_slug}/chunk-upload/")
|
||||
async def get_chunk_upload_info(request: AuthHttpRequest, organization_slug: str):
|
||||
"""Get server settings for chunk file upload"""
|
||||
path = reverse("api:get_chunk_upload_info", args=[organization_slug])
|
||||
url = (
|
||||
path
|
||||
if settings.GLITCHTIP_CHUNK_UPLOAD_USE_RELATIVE_URL
|
||||
else settings.GLITCHTIP_URL.geturl() + path
|
||||
)
|
||||
return {
|
||||
"url": url,
|
||||
"chunkSize": CHUNK_UPLOAD_BLOB_SIZE,
|
||||
"chunksPerRequest": MAX_CHUNKS_PER_REQUEST,
|
||||
"maxFileSize": 2147483648,
|
||||
"maxRequestSize": MAX_REQUEST_SIZE,
|
||||
"concurrency": MAX_CONCURRENCY,
|
||||
"hashAlgorithm": HASH_ALGORITHM,
|
||||
"compression": ["gzip"],
|
||||
"accept": CHUNK_UPLOAD_ACCEPT,
|
||||
}
|
||||
|
||||
|
||||
@optional_slash(router, "post", "organizations/{slug:organization_slug}/chunk-upload/")
|
||||
@has_permission(["project:write", "project:admin", "project:releases"])
|
||||
async def chunk_upload(
|
||||
request: AuthHttpRequest,
|
||||
organization_slug: str,
|
||||
file_gzip: list[UploadedFile] = File(...),
|
||||
):
|
||||
"""Upload one more more gzipped files to save"""
|
||||
logger = logging.getLogger("glitchtip.files")
|
||||
logger.info("chunkupload.start")
|
||||
|
||||
organization = await aget_object_or_404(
|
||||
Organization, slug=organization_slug.lower(), users=request.auth.user_id
|
||||
)
|
||||
|
||||
files = [GzipChunk(chunk) for chunk in file_gzip]
|
||||
|
||||
if len(files) == 0:
|
||||
# No files uploaded is ok
|
||||
logger.info("chunkupload.end", extra={"status": 200})
|
||||
return
|
||||
|
||||
logger.info("chunkupload.post.files", extra={"len": len(files)})
|
||||
|
||||
# Validate file size
|
||||
checksums = []
|
||||
size = 0
|
||||
for chunk in files:
|
||||
size += chunk.size
|
||||
if chunk.size > CHUNK_UPLOAD_BLOB_SIZE:
|
||||
logger.info("chunkupload.end", extra={"status": 400})
|
||||
raise HttpError(400, "Chunk size too large")
|
||||
checksums.append(chunk.name)
|
||||
|
||||
if size > MAX_REQUEST_SIZE:
|
||||
logger.info("chunkupload.end", extra={"status": 400})
|
||||
raise HttpError(400, "Request too large")
|
||||
|
||||
if len(files) > MAX_CHUNKS_PER_REQUEST:
|
||||
logger.info("chunkupload.end", extra={"status": 400})
|
||||
raise HttpError(400, "Too many chunks")
|
||||
|
||||
try:
|
||||
await FileBlob.from_files(
|
||||
zip(files, checksums), organization=organization, logger=logger
|
||||
)
|
||||
except IOError as err:
|
||||
logger.info("chunkupload.end", extra={"status": 400})
|
||||
raise HttpError(400, str(err)) from err
|
||||
|
||||
logger.info("chunkupload.end", extra={"status": 200})
|
||||
43
homelab/otel/alloy.river
Normal file
43
homelab/otel/alloy.river
Normal file
@@ -0,0 +1,43 @@
|
||||
// Grafana Alloy — Faro RUM receiver
|
||||
//
|
||||
// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the
|
||||
// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk.
|
||||
//
|
||||
// Pipeline:
|
||||
// faro.receiver → receives HTTP POST /collect from browsers
|
||||
// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo
|
||||
// loki.write → forwards logs/exceptions to Loki
|
||||
//
|
||||
// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared.
|
||||
// CORS is configured to allow requests from libnovel.cc.
|
||||
|
||||
faro.receiver "faro" {
|
||||
server {
|
||||
listen_address = "0.0.0.0"
|
||||
listen_port = 12347
|
||||
|
||||
cors_allowed_origins = ["https://libnovel.cc", "https://www.libnovel.cc"]
|
||||
}
|
||||
|
||||
output {
|
||||
logs = [loki.write.faro.receiver]
|
||||
traces = [otelcol.exporter.otlphttp.faro.input]
|
||||
}
|
||||
}
|
||||
|
||||
// Forward Faro traces to the OTel Collector (which routes to Tempo)
|
||||
otelcol.exporter.otlphttp "faro" {
|
||||
client {
|
||||
endpoint = "http://otel-collector:4318"
|
||||
tls {
|
||||
insecure = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward Faro logs/exceptions directly to Loki
|
||||
loki.write "faro" {
|
||||
endpoint {
|
||||
url = "http://loki:3100/loki/api/v1/push"
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ extensions:
|
||||
|
||||
service:
|
||||
extensions: [health_check, pprof]
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 0.0.0.0:8888
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"uid": "libnovel-backend",
|
||||
"title": "Backend API",
|
||||
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.",
|
||||
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics.",
|
||||
"tags": ["libnovel", "backend", "api"],
|
||||
"timezone": "browser",
|
||||
"refresh": "30s",
|
||||
@@ -173,7 +173,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60",
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
|
||||
"legendFormat": "5xx/min",
|
||||
"instant": true
|
||||
}
|
||||
@@ -182,7 +182,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "Request Rate by Status",
|
||||
"title": "Request Rate (total vs errors)",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
@@ -191,27 +191,21 @@
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
|
||||
{ "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "success",
|
||||
"refId": "total",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))",
|
||||
"legendFormat": "2xx"
|
||||
},
|
||||
{
|
||||
"refId": "notfound",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))",
|
||||
"legendFormat": "4xx"
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))",
|
||||
"legendFormat": "total"
|
||||
},
|
||||
{
|
||||
"refId": "errors",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))",
|
||||
"legendFormat": "5xx"
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m]))",
|
||||
"legendFormat": "errors"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -248,50 +242,30 @@
|
||||
{
|
||||
"id": 12,
|
||||
"type": "timeseries",
|
||||
"title": "Requests / min by HTTP method (UI → Backend)",
|
||||
"title": "Request Rate by Span Name (top operations)",
|
||||
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
|
||||
"description": "Throughput broken down by HTTP route / span name from Tempo span metrics.",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60",
|
||||
"legendFormat": "{{http_request_method}}"
|
||||
"expr": "topk(10, sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])) by (span_name))",
|
||||
"legendFormat": "{{span_name}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "timeseries",
|
||||
"title": "Requests / min — UI → PocketBase",
|
||||
"title": "Latency by Span Name (p95)",
|
||||
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
|
||||
"description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60",
|
||||
"legendFormat": "{{http_request_method}} {{http_response_status_code}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "timeseries",
|
||||
"title": "UI → Backend Latency (p50 / p95)",
|
||||
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
|
||||
"description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.",
|
||||
"description": "p95 latency per operation — helps identify slow endpoints.",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
@@ -302,13 +276,8 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
|
||||
"legendFormat": "p95"
|
||||
"expr": "topk(10, histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le, span_name)))",
|
||||
"legendFormat": "{{span_name}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -316,7 +285,7 @@
|
||||
"id": 20,
|
||||
"type": "logs",
|
||||
"title": "Backend Errors",
|
||||
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 },
|
||||
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_running",
|
||||
"expr": "runner_tasks_running",
|
||||
"legendFormat": "running",
|
||||
"instant": true
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_completed_total",
|
||||
"expr": "runner_tasks_completed_total",
|
||||
"legendFormat": "completed",
|
||||
"instant": true
|
||||
}
|
||||
@@ -93,7 +93,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_failed_total",
|
||||
"expr": "runner_tasks_failed_total",
|
||||
"legendFormat": "failed",
|
||||
"instant": true
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_uptime_seconds",
|
||||
"expr": "runner_uptime_seconds",
|
||||
"legendFormat": "uptime",
|
||||
"instant": true
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)",
|
||||
"expr": "runner_tasks_failed_total / clamp_min(runner_tasks_completed_total + runner_tasks_failed_total, 1)",
|
||||
"legendFormat": "failure rate",
|
||||
"instant": true
|
||||
}
|
||||
@@ -215,17 +215,17 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60",
|
||||
"expr": "rate(runner_tasks_completed_total[5m]) * 60",
|
||||
"legendFormat": "completed"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60",
|
||||
"expr": "rate(runner_tasks_failed_total[5m]) * 60",
|
||||
"legendFormat": "failed"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_running",
|
||||
"expr": "runner_tasks_running",
|
||||
"legendFormat": "running"
|
||||
}
|
||||
]
|
||||
|
||||
284
homelab/otel/grafana/provisioning/dashboards/web-vitals.json
Normal file
284
homelab/otel/grafana/provisioning/dashboards/web-vitals.json
Normal file
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"uid": "libnovel-web-vitals",
|
||||
"title": "Web Vitals (RUM)",
|
||||
"description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser → Alloy faro.receiver → Tempo (traces) + Loki (logs).",
|
||||
"tags": ["libnovel", "frontend", "rum", "web-vitals"],
|
||||
"timezone": "browser",
|
||||
"refresh": "1m",
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"schemaVersion": 39,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "LCP — p75 (Largest Contentful Paint)",
|
||||
"description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 2500 },
|
||||
{ "color": "red", "value": 4000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "LCP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "INP — p75 (Interaction to Next Paint)",
|
||||
"description": "Good < 200 ms, needs improvement < 500 ms, poor ≥ 500 ms.",
|
||||
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 200 },
|
||||
{ "color": "red", "value": 500 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "INP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "CLS — p75 (Cumulative Layout Shift)",
|
||||
"description": "Good < 0.1, needs improvement < 0.25, poor ≥ 0.25.",
|
||||
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 3,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.1 },
|
||||
{ "color": "red", "value": 0.25 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))",
|
||||
"legendFormat": "CLS p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "TTFB — p75 (Time to First Byte)",
|
||||
"description": "Good < 800 ms, needs improvement < 1800 ms, poor ≥ 1800 ms.",
|
||||
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 800 },
|
||||
{ "color": "red", "value": 1800 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "TTFB p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "stat",
|
||||
"title": "FCP — p75 (First Contentful Paint)",
|
||||
"description": "Good < 1.8 s, needs improvement < 3 s, poor ≥ 3 s.",
|
||||
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1800 },
|
||||
{ "color": "red", "value": 3000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "FCP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "stat",
|
||||
"title": "Active Sessions (30 min)",
|
||||
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))",
|
||||
"legendFormat": "sessions",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "LCP over time (p50 / p75 / p95)",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
|
||||
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] },
|
||||
{ "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p75"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p95"
|
||||
},
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "timeseries",
|
||||
"title": "TTFB over time (p50 / p75 / p95)",
|
||||
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
|
||||
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p75"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p95"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "logs",
|
||||
"title": "Frontend Errors & Exceptions",
|
||||
"description": "JS exceptions and console errors captured by Faro and shipped to Loki.",
|
||||
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": true,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": true,
|
||||
"enableLogDetails": true,
|
||||
"sortOrder": "Descending",
|
||||
"dedupStrategy": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"type": "logs",
|
||||
"title": "Frontend Logs (all Faro events)",
|
||||
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": true,
|
||||
"enableLogDetails": true,
|
||||
"sortOrder": "Descending",
|
||||
"dedupStrategy": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{service_name=\"libnovel-ui\"}",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -294,6 +294,23 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
{"name":"slug", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"from_item", "type":"number"},
|
||||
{"name":"to_item", "type":"number"},
|
||||
{"name":"items_done", "type":"number"},
|
||||
{"name":"items_total", "type":"number"},
|
||||
{"name":"model", "type":"text"},
|
||||
{"name":"payload", "type":"text"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "discovery_votes" '{
|
||||
"name":"discovery_votes","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "AI Jobs",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
@@ -410,6 +411,7 @@
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Push",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tâches IA",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
@@ -471,5 +472,6 @@
|
||||
"feed_chapters_label": "{n} chapitres",
|
||||
"feed_browse_cta": "Parcourir le catalogue",
|
||||
"feed_find_users_cta": "Trouver des lecteurs",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tugas AI",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
@@ -471,5 +472,6 @@
|
||||
"feed_chapters_label": "{n} bab",
|
||||
"feed_browse_cta": "Jelajahi katalog",
|
||||
"feed_find_users_cta": "Temukan pembaca",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tarefas de IA",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
@@ -471,5 +472,6 @@
|
||||
"feed_chapters_label": "{n} capítulos",
|
||||
"feed_browse_cta": "Ver catálogo",
|
||||
"feed_find_users_cta": "Encontrar leitores",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Задачи ИИ",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
@@ -471,5 +472,6 @@
|
||||
"feed_chapters_label": "{n} глав",
|
||||
"feed_browse_cta": "Каталог",
|
||||
"feed_find_users_cta": "Найти читателей",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
142
ui/package-lock.json
generated
142
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
@@ -1689,6 +1690,115 @@
|
||||
"module-details-from-path": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.3.1.tgz",
|
||||
"integrity": "sha512-htDKO0YFKr0tfntrPoM151vOPSZzmP6oE0+0MDvbI1WDaBW4erXmYi3feGJLWDXt5/vZBg9iQRmZoRzTLTTcOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/otlp-transformer": "^0.213.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.213.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz",
|
||||
"integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.213.0",
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/sdk-logs": "0.213.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"protobufjs": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz",
|
||||
"integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.213.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz",
|
||||
"integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.213.0",
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz",
|
||||
"integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz",
|
||||
"integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-web-sdk": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-2.3.1.tgz",
|
||||
"integrity": "sha512-WMfErl2YSP+CcfcobMpCdK6apX86hc8bymMXsvYLQpBBkQ0KJjIilEQS/YXd+g/cg6F1kwbeweisBKluNNy5sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grafana/faro-core": "^2.3.1",
|
||||
"ua-parser-js": "1.0.41",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
@@ -7377,6 +7487,32 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
@@ -7540,6 +7676,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
|
||||
|
||||
// Sentry / GlitchTip client-side error tracking.
|
||||
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
|
||||
@@ -13,4 +14,21 @@ if (env.PUBLIC_GLITCHTIP_DSN) {
|
||||
});
|
||||
}
|
||||
|
||||
// Grafana Faro RUM — browser performance monitoring (Web Vitals, traces, errors).
|
||||
// No-op when PUBLIC_FARO_COLLECTOR_URL is unset (e.g. local dev).
|
||||
if (env.PUBLIC_FARO_COLLECTOR_URL) {
|
||||
initializeFaro({
|
||||
url: env.PUBLIC_FARO_COLLECTOR_URL,
|
||||
app: {
|
||||
name: 'libnovel-ui',
|
||||
version: env.PUBLIC_BUILD_VERSION || 'dev',
|
||||
environment: 'production'
|
||||
},
|
||||
instrumentations: [
|
||||
// Core Web Vitals (LCP, CLS, INP, TTFB, FCP) + JS errors + console
|
||||
...getWebInstrumentations({ captureConsole: false })
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
* It only runs once per chapter (guarded by nextStatus !== 'none').
|
||||
*/
|
||||
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||
|
||||
@@ -50,6 +52,9 @@ class AudioStore {
|
||||
/** Full chapter list for the currently loaded book (number + title). */
|
||||
chapters = $state<{ number: number; title: string }[]>([]);
|
||||
|
||||
/** Available voices (populated by the chapter AudioPlayer on mount). */
|
||||
voices = $state<Voice[]>([]);
|
||||
|
||||
// ── Loading/generation state ────────────────────────────────────────────
|
||||
status = $state<AudioStatus>('idle');
|
||||
audioUrl = $state('');
|
||||
|
||||
@@ -226,6 +226,11 @@
|
||||
audioStore.nextChapter = nextChapter ?? null;
|
||||
});
|
||||
|
||||
// Keep voices in store up to date whenever prop changes.
|
||||
$effect(() => {
|
||||
if (voices.length > 0) audioStore.voices = voices;
|
||||
});
|
||||
|
||||
// Auto-start: if the layout navigated here via auto-next, kick off playback.
|
||||
// We match against the chapter prop so the outgoing chapter's AudioPlayer
|
||||
// (still mounted during the brief navigation window) never reacts to this.
|
||||
@@ -530,6 +535,7 @@
|
||||
audioStore.bookTitle = bookTitle;
|
||||
audioStore.cover = cover;
|
||||
audioStore.chapters = chapters;
|
||||
if (voices.length > 0) audioStore.voices = voices;
|
||||
|
||||
// Update OS media session (lock screen / notification center).
|
||||
setMediaSession();
|
||||
@@ -1102,72 +1108,7 @@
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next toggle (keep here as useful context) -->
|
||||
{#if nextChapter !== null && nextChapter !== undefined}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepAfterChapter
|
||||
? 'Stop after this chapter'
|
||||
: audioStore.sleepUntil
|
||||
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
|
||||
: 'Sleep timer off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepAfterChapter}
|
||||
End Ch.
|
||||
{:else if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
|
||||
<div class="mt-2">
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{m.reader_ch_ready({ n: String(nextChapter) })}
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if audioStore.active}
|
||||
|
||||
451
ui/src/lib/components/ListeningMode.svelte
Normal file
451
ui/src/lib/components/ListeningMode.svelte
Normal file
@@ -0,0 +1,451 @@
|
||||
<script lang="ts">
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
|
||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||
const voices = $derived(audioStore.voices);
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
let showVoicePanel = $state(false);
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio: HTMLAudioElement | null = null;
|
||||
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
const found = voices.find((x) => x.id === v);
|
||||
if (found) return voiceLabel(found);
|
||||
const id = v as string;
|
||||
return id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
const base = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return v.lang !== 'en-us' ? `${base} (${v.lang})` : base;
|
||||
}
|
||||
|
||||
function stopSample() {
|
||||
if (sampleAudio) {
|
||||
sampleAudio.pause();
|
||||
sampleAudio.src = '';
|
||||
sampleAudio = null;
|
||||
}
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
|
||||
async function playSample(voiceId: string) {
|
||||
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
||||
stopSample();
|
||||
samplePlayingVoice = voiceId;
|
||||
try {
|
||||
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
||||
if (!res.ok) { samplePlayingVoice = null; return; }
|
||||
const { url } = (await res.json()) as { url: string };
|
||||
sampleAudio = new Audio(url);
|
||||
sampleAudio.onended = () => stopSample();
|
||||
sampleAudio.onerror = () => stopSample();
|
||||
sampleAudio.play().catch(() => stopSample());
|
||||
} catch {
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectVoice(voiceId: string) {
|
||||
stopSample();
|
||||
audioStore.voice = voiceId;
|
||||
showVoicePanel = false;
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
void audioStore.currentTime; // re-run every second while playing
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
} else if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
} else {
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return 'Off';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ''}` : `${s}s`;
|
||||
}
|
||||
|
||||
const sleepLabel = $derived(
|
||||
audioStore.sleepAfterChapter
|
||||
? 'End Ch.'
|
||||
: audioStore.sleepUntil > Date.now()
|
||||
? formatSleepRemaining(sleepRemainingSec)
|
||||
: 'Sleep'
|
||||
);
|
||||
|
||||
// ── Format time ──────────────────────────────────────────────────────────
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Playback controls ────────────────────────────────────────────────────
|
||||
function seek(e: Event) {
|
||||
audioStore.seekRequest = Number((e.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
function skipBack() {
|
||||
audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15);
|
||||
}
|
||||
function skipForward() {
|
||||
audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30);
|
||||
}
|
||||
function togglePlay() {
|
||||
audioStore.toggleRequest++;
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showVoicePanel) { showVoicePanel = false; }
|
||||
else { onclose(); }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Full-screen listening mode overlay -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Blurred cover background -->
|
||||
{#if audioStore.cover}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
|
||||
style="background-image: url('{audioStore.cover}');"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="relative flex items-center justify-between px-4 py-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close listening mode"
|
||||
>
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Now Playing</span>
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showVoicePanel = !showVoicePanel)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoicePanel
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Voice panel (inline dropdown below header) -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="relative mx-4 mb-2 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-3 z-10 overflow-y-auto max-h-56 shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Select Voice</p>
|
||||
<button type="button" onclick={() => { stopSample(); showVoicePanel = false; }} class="text-(--color-muted) hover:text-(--color-text) transition-colors" aria-label="Close voice panel">
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
</button>
|
||||
</div>
|
||||
{#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]}
|
||||
{#if group.length > 0}
|
||||
<p class="text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0">{label}</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each group as v (v.id)}
|
||||
<div class="flex items-center rounded-lg border overflow-hidden text-xs
|
||||
{audioStore.voice === v.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10'
|
||||
: 'border-(--color-border) bg-(--color-surface-3)'}">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectVoice(v.id)}
|
||||
class="px-2 py-1 font-medium transition-colors
|
||||
{audioStore.voice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>{voiceLabel(v)}</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
||||
class="px-1.5 py-1 border-l border-(--color-border) transition-colors
|
||||
{samplePlayingVoice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||
>
|
||||
{#if samplePlayingVoice === v.id}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Cover art + track info -->
|
||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
|
||||
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
|
||||
</p>
|
||||
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<div class="px-6 shrink-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||
<span>{formatTime(audioStore.currentTime)}</span>
|
||||
<span>{formatTime(audioStore.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.speed = s)}
|
||||
class={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||
audioStore.speed === s
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title="Sleep timer"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{sleepLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2.5 border-b border-(--color-border)">Chapters</p>
|
||||
<div class="overflow-y-auto max-h-64">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={onclose}
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
|
||||
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
|
||||
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,6 +374,7 @@ export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_catalogue_tools.js'
|
||||
export * from './admin_nav_ai_jobs.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
@@ -381,6 +382,7 @@ export * from './admin_nav_logs.js'
|
||||
export * from './admin_nav_uptime.js'
|
||||
export * from './admin_nav_push.js'
|
||||
export * from './admin_nav_gitea.js'
|
||||
export * from './admin_nav_grafana.js'
|
||||
export * from './admin_scrape_status_idle.js'
|
||||
export * from './admin_scrape_full_catalogue.js'
|
||||
export * from './admin_scrape_single_book.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Ai_JobsInputs */
|
||||
|
||||
const en_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`AI Jobs`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Задачи ИИ`)
|
||||
};
|
||||
|
||||
const id_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tugas AI`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tarefas de IA`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tâches IA`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "AI Jobs" |
|
||||
*
|
||||
* @param {Admin_Nav_Ai_JobsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_ai_jobs = /** @type {((inputs?: Admin_Nav_Ai_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Ai_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "id") return id_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_ai_jobs(inputs)
|
||||
return fr_admin_nav_ai_jobs(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_grafana.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_grafana.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_GrafanaInputs */
|
||||
|
||||
const en_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const id_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Grafana" |
|
||||
*
|
||||
* @param {Admin_Nav_GrafanaInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_grafana = /** @type {((inputs?: Admin_Nav_GrafanaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GrafanaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_grafana(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_grafana(inputs)
|
||||
if (locale === "id") return id_admin_nav_grafana(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_grafana(inputs)
|
||||
return fr_admin_nav_grafana(inputs)
|
||||
});
|
||||
@@ -14,6 +14,23 @@ const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIJob {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
|
||||
from_item: number;
|
||||
to_item: number;
|
||||
items_done: number;
|
||||
items_total: number;
|
||||
model: string;
|
||||
payload: string;
|
||||
error_message?: string;
|
||||
started?: string;
|
||||
finished?: string;
|
||||
heartbeat_at?: string;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -2162,3 +2179,13 @@ export async function getUserStats(
|
||||
streak
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all AI jobs from PocketBase, sorted by started descending.
|
||||
* No caching — admin views always want fresh data.
|
||||
*/
|
||||
export async function listAIJobs(): Promise<AIJob[]> {
|
||||
return listAll<AIJob>('ai_jobs', '', '-started');
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
@@ -33,6 +34,50 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cached admin model lists ─────────────────────────────────────────────────
|
||||
|
||||
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change
|
||||
|
||||
/**
|
||||
* Fetch image-gen model list from the Go backend with a 10-minute cache.
|
||||
* Returns an empty array on error (callers should surface a warning).
|
||||
*/
|
||||
export async function listImageModels<T>(): Promise<T[]> {
|
||||
const key = 'backend:models:image-gen';
|
||||
const cached = await cache.get<T[]>(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const models = (data.models ?? []) as T[];
|
||||
await cache.set(key, models, MODELS_CACHE_TTL);
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text-gen model list from the Go backend with a 10-minute cache.
|
||||
* Returns an empty array on error.
|
||||
*/
|
||||
export async function listTextModels<T>(): Promise<T[]> {
|
||||
const key = 'backend:models:text-gen';
|
||||
const cached = await cache.get<T[]>(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const models = (data.models ?? []) as T[];
|
||||
await cache.set(key, models, MODELS_CACHE_TTL);
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response types ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -34,6 +35,7 @@
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
@@ -287,7 +289,7 @@
|
||||
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
|
||||
}
|
||||
|
||||
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
|
||||
const speedSteps = [0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
function cycleSpeed() {
|
||||
const idx = speedSteps.indexOf(audioStore.speed);
|
||||
@@ -778,9 +780,10 @@
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -794,26 +797,29 @@
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
<!-- Scrollable list -->
|
||||
<div class="overflow-y-auto px-4">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -989,6 +995,21 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Headphones: open listening mode -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (listeningModeOpen = true)}
|
||||
title="Listening mode"
|
||||
aria-label="Open listening mode"
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
>
|
||||
<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 18v-6a9 9 0 0118 0v6"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<!-- Dismiss -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1004,4 +1025,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening mode full-screen overlay -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
|
||||
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
|
||||
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() },
|
||||
{ href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
@@ -19,6 +20,7 @@
|
||||
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
|
||||
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
|
||||
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
|
||||
{ href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() },
|
||||
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
|
||||
];
|
||||
|
||||
|
||||
14
ui/src/routes/admin/ai-jobs/+page.server.ts
Normal file
14
ui/src/routes/admin/ai-jobs/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listAIJobs, type AIJob } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
return { jobs };
|
||||
};
|
||||
326
ui/src/routes/admin/ai-jobs/+page.svelte
Normal file
326
ui/src/routes/admin/ai-jobs/+page.svelte
Normal file
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AIJob } from '$lib/server/pocketbase';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
|
||||
|
||||
// Keep in sync on server reloads
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(() => {
|
||||
invalidateAll();
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Filter / search ───────────────────────────────────────────────────────────
|
||||
let query = $state('');
|
||||
let statusFilter = $state<string>('all');
|
||||
|
||||
const STATUS_OPTIONS = ['all', 'pending', 'running', 'done', 'failed', 'cancelled'] as const;
|
||||
|
||||
let filteredJobs = $derived(
|
||||
jobs.filter((j) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQ =
|
||||
!q ||
|
||||
j.slug.toLowerCase().includes(q) ||
|
||||
j.kind.toLowerCase().includes(q) ||
|
||||
j.model.toLowerCase().includes(q) ||
|
||||
(j.error_message ?? '').toLowerCase().includes(q);
|
||||
const matchesStatus = statusFilter === 'all' || j.status === statusFilter;
|
||||
return matchesQ && matchesStatus;
|
||||
})
|
||||
);
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
running: jobs.filter((j) => j.status === 'running').length,
|
||||
pending: jobs.filter((j) => j.status === 'pending').length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length
|
||||
});
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingId = $state<string | null>(null);
|
||||
let cancelError = $state('');
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingId) return;
|
||||
cancellingId = id;
|
||||
cancelError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
cancelError = body.error ?? `Error ${res.status}`;
|
||||
} else {
|
||||
await invalidateAll();
|
||||
}
|
||||
} catch {
|
||||
cancelError = 'Network error.';
|
||||
} finally {
|
||||
cancellingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function statusBg(status: string) {
|
||||
if (status === 'done') return 'bg-green-400/10 text-green-400';
|
||||
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
|
||||
if (status === 'pending') return 'bg-sky-400/10 text-sky-400';
|
||||
if (status === 'failed') return 'bg-(--color-danger)/10 text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'bg-zinc-700/50 text-(--color-muted)';
|
||||
return 'bg-zinc-700/50 text-(--color-text)';
|
||||
}
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'chapter-names': 'Chapter Names',
|
||||
'batch-covers': 'Batch Covers',
|
||||
'chapter-covers': 'Chapter Covers',
|
||||
'refresh-metadata': 'Refresh Metadata'
|
||||
};
|
||||
return labels[kind] ?? kind;
|
||||
}
|
||||
|
||||
function fmtDate(s: string | undefined) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function duration(started: string | undefined, finished: string | undefined) {
|
||||
if (!started || !finished) return '—';
|
||||
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function progress(job: AIJob) {
|
||||
if (!job.items_total) return null;
|
||||
return Math.round((job.items_done / job.items_total) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => invalidateAll()}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{#each [
|
||||
{ label: 'Total', value: stats.total, color: 'text-(--color-text)' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-(--color-brand)' },
|
||||
{ label: 'Pending', value: stats.pending, color: 'text-sky-400' },
|
||||
{ label: 'Done', value: stats.done, color: 'text-green-400' },
|
||||
{ label: 'Failed', value: stats.failed, color: 'text-(--color-danger)' }
|
||||
] as stat}
|
||||
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-4 py-3">
|
||||
<p class="text-xs text-(--color-muted) mb-1">{stat.label}</p>
|
||||
<p class={cn('text-xl font-bold tabular-nums', stat.color)}>{stat.value}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search slug, kind, model…"
|
||||
bind:value={query}
|
||||
class="flex-1 min-w-48 px-3 py-1.5 rounded-md bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#each STATUS_OPTIONS as s}
|
||||
<button
|
||||
onclick={() => (statusFilter = s)}
|
||||
class={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize',
|
||||
statusFilter === s
|
||||
? 'bg-(--color-brand) text-black'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel error -->
|
||||
{#if cancelError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Jobs table -->
|
||||
{#if filteredJobs.length === 0}
|
||||
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
|
||||
<p class="text-(--color-muted) text-sm">
|
||||
{jobs.length === 0 ? 'No AI jobs found.' : 'No jobs match your filters.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Kind</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Slug</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden sm:table-cell">Model</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden md:table-cell">Progress</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Started</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Duration</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)">
|
||||
{#each filteredJobs as job (job.id)}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||
<!-- Status badge -->
|
||||
<td class="px-4 py-3">
|
||||
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Kind -->
|
||||
<td class="px-4 py-3 font-medium text-(--color-text)">
|
||||
{kindLabel(job.kind)}
|
||||
</td>
|
||||
|
||||
<!-- Slug -->
|
||||
<td class="px-4 py-3 max-w-[12rem]">
|
||||
{#if job.slug}
|
||||
<a
|
||||
href="/admin/image-gen?slug={job.slug}"
|
||||
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
|
||||
>
|
||||
{job.slug}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Model -->
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<span class="font-mono text-xs text-(--color-muted) truncate block max-w-[10rem]" title={job.model}>
|
||||
{job.model || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Progress -->
|
||||
<td class="px-4 py-3 hidden md:table-cell">
|
||||
{#if job.items_total > 0}
|
||||
{@const pct = progress(job)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20 h-1.5 rounded-full bg-(--color-border) overflow-hidden">
|
||||
<div
|
||||
class={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
job.status === 'done' ? 'bg-green-400' :
|
||||
job.status === 'failed' ? 'bg-(--color-danger)' :
|
||||
'bg-(--color-brand)'
|
||||
)}
|
||||
style="width: {pct}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums whitespace-nowrap">
|
||||
{job.items_done}/{job.items_total}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Started -->
|
||||
<td class="px-4 py-3 hidden lg:table-cell">
|
||||
<span class="text-xs text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</span>
|
||||
</td>
|
||||
|
||||
<!-- Duration -->
|
||||
<td class="px-4 py-3 hidden lg:table-cell">
|
||||
<span class="text-xs text-(--color-muted) whitespace-nowrap tabular-nums">
|
||||
{duration(job.started, job.finished)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingId === job.id}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<span
|
||||
class="text-xs text-(--color-danger) max-w-[12rem] truncate"
|
||||
title={job.error_message}
|
||||
>
|
||||
{job.error_message}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Showing {filteredJobs.length} of {jobs.length} jobs
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listImageModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
@@ -12,13 +12,10 @@ export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
let imgModels: ImageModelInfo[] = [];
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
imgModels = (data.models ?? []) as ImageModelInfo[];
|
||||
}
|
||||
imgModels = await listImageModels<ImageModelInfo>();
|
||||
} catch (e) {
|
||||
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
|
||||
}
|
||||
return { imgModels };
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
|
||||
|
||||
// ── Batch covers ──────────────────────────────────────────────────────────────
|
||||
let fromItem = $state(0);
|
||||
let toItem = $state(0);
|
||||
let resumeJobID = $state('');
|
||||
let running = $state(false);
|
||||
let jobID = $state('');
|
||||
let done = $state(0);
|
||||
@@ -62,6 +65,9 @@
|
||||
num_steps: numSteps || undefined,
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
from_item: fromItem || undefined,
|
||||
to_item: toItem || undefined,
|
||||
job_id: resumeJobID.trim() || undefined,
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -172,6 +178,21 @@
|
||||
<input id="height" type="number" bind:value={height} min="0" step="64"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="from-item">From <span class="font-normal">(0=start)</span></label>
|
||||
<input id="from-item" type="number" bind:value={fromItem} min="0"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="to-item">To <span class="font-normal">(0=end)</span></label>
|
||||
<input id="to-item" type="number" bind:value={toItem} min="0"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="col-span-2 sm:col-span-4 space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="resume-job">Resume job ID <span class="font-normal">(leave blank to start fresh)</span></label>
|
||||
<input id="resume-job" type="text" bind:value={resumeJobID} placeholder="optional PB job ID"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export interface Release {
|
||||
id: number;
|
||||
@@ -13,7 +14,15 @@ export interface Release {
|
||||
const GITEA_RELEASES_URL =
|
||||
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
|
||||
|
||||
const CACHE_KEY = 'admin:changelog:releases';
|
||||
const CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const cached = await cache.get<Release[]>(CACHE_KEY);
|
||||
if (cached) {
|
||||
return { releases: cached };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
@@ -22,7 +31,9 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
return { releases: [], error: `Gitea API returned ${res.status}` };
|
||||
}
|
||||
const releases: Release[] = await res.json();
|
||||
return { releases: releases.filter((r) => !r.draft) };
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return { releases: filtered };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listImageModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
@@ -21,23 +21,18 @@ export interface BookSummary {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as ImageModelInfo[];
|
||||
})(),
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listImageModels<ImageModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
|
||||
@@ -193,6 +193,33 @@
|
||||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||||
}
|
||||
|
||||
// ── Auto-prompt ──────────────────────────────────────────────────────────────
|
||||
let autoPrompting = $state(false);
|
||||
let autoPromptError = $state('');
|
||||
|
||||
async function autoGeneratePrompt() {
|
||||
if (!slug.trim() || autoPrompting) return;
|
||||
autoPrompting = true;
|
||||
autoPromptError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen/auto-prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: slug.trim(), type: imageType, chapter: imageType === 'chapter' ? chapter : 0 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
autoPromptError = body.error ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
prompt = body.prompt ?? '';
|
||||
} catch {
|
||||
autoPromptError = 'Network error.';
|
||||
} finally {
|
||||
autoPrompting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Style presets ────────────────────────────────────────────────────────────
|
||||
const PRESETS_KEY = 'admin_image_gen_presets_v1';
|
||||
|
||||
@@ -328,7 +355,13 @@
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
if (res.status === 502 || res.status === 504) {
|
||||
genError =
|
||||
body.error ??
|
||||
`Generation timed out (${res.status}). FLUX models can take 60–120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
|
||||
} else {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -583,8 +616,14 @@
|
||||
Prompt
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{#if slug.trim()}
|
||||
<button onclick={autoGeneratePrompt} disabled={autoPrompting}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors disabled:opacity-50">
|
||||
{autoPrompting ? 'Generating…' : 'Auto-prompt'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if selectedBook?.summary}
|
||||
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
|
||||
<button onclick={injectDescription} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Inject description
|
||||
</button>
|
||||
{/if}
|
||||
@@ -617,6 +656,9 @@
|
||||
placeholder="Describe the image to generate…"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
{#if autoPromptError}
|
||||
<p class="text-xs text-(--color-danger)">{autoPromptError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
|
||||
@@ -718,6 +760,13 @@
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
|
||||
<!-- Cloudflare AI timeout warning -->
|
||||
{#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')}
|
||||
<p class="text-xs text-amber-400/80 bg-amber-400/10 rounded px-2.5 py-1.5">
|
||||
Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- num_steps -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listTextModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
@@ -18,23 +18,18 @@ export interface TextModelInfo {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as TextModelInfo[];
|
||||
})(),
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listTextModels<TextModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
let chAC = makeBookAC();
|
||||
let chSlug = $state('');
|
||||
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
|
||||
let chFromChapter = $state(0);
|
||||
let chToChapter = $state(0);
|
||||
let chJobID = $state('');
|
||||
let chGenerating = $state(false);
|
||||
let chError = $state('');
|
||||
|
||||
@@ -122,6 +125,7 @@
|
||||
chBatchWarnings = [];
|
||||
chApplySuccess = false;
|
||||
chApplyError = '';
|
||||
chJobID = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
@@ -130,7 +134,9 @@
|
||||
body: JSON.stringify({
|
||||
slug: chSlug.trim(),
|
||||
pattern: chPattern.trim(),
|
||||
model: selectedModel
|
||||
model: selectedModel,
|
||||
from_chapter: chFromChapter || undefined,
|
||||
to_chapter: chToChapter || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -161,6 +167,7 @@
|
||||
if (!payload) continue;
|
||||
|
||||
let evt: {
|
||||
job_id?: string;
|
||||
batch?: number;
|
||||
total_batches?: number;
|
||||
chapters_done?: number;
|
||||
@@ -176,6 +183,8 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt.job_id) chJobID = evt.job_id;
|
||||
|
||||
if (evt.done) {
|
||||
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
|
||||
chGenerating = false;
|
||||
@@ -579,6 +588,27 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Range (optional) -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-from">
|
||||
From chapter <span class="font-normal">(0=all)</span>
|
||||
</label>
|
||||
<input id="ch-from" type="number" bind:value={chFromChapter} min="0"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-to">
|
||||
To chapter <span class="font-normal">(0=all)</span>
|
||||
</label>
|
||||
<input id="ch-to" type="number" bind:value={chToChapter} min="0"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
</div>
|
||||
{#if chJobID}
|
||||
<p class="text-xs text-(--color-muted)">Job: <span class="font-mono">{chJobID}</span></p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={generateChapterNames}
|
||||
disabled={!chCanGenerate}
|
||||
|
||||
29
ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts
Normal file
29
ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* POST /api/admin/ai-jobs/[id]/cancel
|
||||
*
|
||||
* Admin-only proxy to the Go backend's AI job cancel endpoint.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
log.error('admin/ai-jobs/cancel', 'backend proxy error', { id, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -22,14 +22,6 @@
|
||||
let settingsPanelOpen = $state(false);
|
||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||
|
||||
const READER_THEMES = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
|
||||
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
|
||||
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
|
||||
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
|
||||
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
|
||||
] as const;
|
||||
const READER_FONTS = [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'serif', label: 'Serif' },
|
||||
@@ -43,14 +35,9 @@
|
||||
] as const;
|
||||
|
||||
// Mirror context values into local reactive state so the panel shows current values
|
||||
let panelTheme = $state(settingsCtx?.current ?? 'amber');
|
||||
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
|
||||
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
|
||||
|
||||
function applyTheme(id: string) {
|
||||
panelTheme = id;
|
||||
if (settingsCtx) settingsCtx.current = id;
|
||||
}
|
||||
function applyFont(id: string) {
|
||||
panelFont = id;
|
||||
if (settingsCtx) settingsCtx.fontFamily = id;
|
||||
@@ -97,34 +84,6 @@
|
||||
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
||||
}
|
||||
|
||||
// ── Listening settings helpers ───────────────────────────────────────────────
|
||||
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
|
||||
const sleepSettingsLabel = $derived(
|
||||
audioStore.sleepAfterChapter
|
||||
? 'End Ch.'
|
||||
: audioStore.sleepUntil > Date.now()
|
||||
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
|
||||
: 'Off'
|
||||
);
|
||||
|
||||
function toggleSleepFromSettings() {
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
} else if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
} else {
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply reading CSS vars whenever layout changes
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
@@ -327,22 +286,36 @@
|
||||
const wordCount = $derived(
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
|
||||
// Audio panel: auto-open if this chapter is already loaded/playing in the store
|
||||
// svelte-ignore state_referenced_locally
|
||||
let audioExpanded = $state(
|
||||
audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number
|
||||
);
|
||||
$effect(() => {
|
||||
// Expand automatically when the store starts playing this chapter
|
||||
if (audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying) {
|
||||
audioExpanded = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode) -->
|
||||
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
||||
{#if layout.readMode === 'scroll'}
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="flex items-center justify-between mb-8 gap-2">
|
||||
<!-- Left: back to chapters list -->
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
|
||||
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-text) text-sm transition-colors shrink-0"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
@@ -350,147 +323,177 @@
|
||||
{m.reader_back_to_chapters()}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- Right: prev/next chapter arrows + settings -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
title="{m.reader_chapter_n({ n: String(data.prev) })}"
|
||||
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
← {m.reader_chapter_n({ n: String(data.prev) })}
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
title="{m.reader_chapter_n({ n: String(data.next) })}"
|
||||
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
{m.reader_chapter_n({ n: String(data.next) })} →
|
||||
<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 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if settingsCtx}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
||||
aria-label="Reader settings"
|
||||
class="p-2 rounded-lg transition-colors hover:bg-(--color-surface-2) {settingsPanelOpen ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
<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="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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-bold text-(--color-text)">
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
{#if wordCount > 0}
|
||||
<p class="text-(--color-muted) text-xs mt-1">
|
||||
{m.reader_words({ n: wordCount.toLocaleString() })}
|
||||
<span class="opacity-50 mx-1">·</span>
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min read
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-(--color-muted) text-xs">Read in:</span>
|
||||
|
||||
<!-- English (original) -->
|
||||
<a
|
||||
href={langUrl('')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
|
||||
{#each SUPPORTED_LANGS as { code, label }}
|
||||
{#if !data.isPro}
|
||||
<!-- Locked for free users -->
|
||||
<a
|
||||
href="/profile"
|
||||
title="Upgrade to Pro to read in {label}"
|
||||
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{label}
|
||||
</a>
|
||||
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
<!-- Spinning indicator while translating -->
|
||||
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
||||
<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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
{:else if currentLang() === code}
|
||||
<!-- Active translated lang -->
|
||||
<a
|
||||
href={langUrl(code)}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
|
||||
>{label}</a>
|
||||
{:else}
|
||||
<!-- Inactive lang: click to request/navigate -->
|
||||
<button
|
||||
onclick={() => requestTranslation(code)}
|
||||
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>{label}</button>
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
|
||||
{#if wordCount > 0}
|
||||
<p class="text-(--color-muted) text-xs">
|
||||
{m.reader_words({ n: wordCount.toLocaleString() })}
|
||||
<span class="opacity-40 mx-0.5">·</span>
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !data.isPro}
|
||||
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
|
||||
{/if}
|
||||
<!-- Language switcher (inline, compact) -->
|
||||
{#if !data.isPreview}
|
||||
<div class="flex items-center gap-1">
|
||||
<a
|
||||
href={langUrl('')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium transition-colors
|
||||
{currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>EN</a>
|
||||
{#each SUPPORTED_LANGS as { code, label }}
|
||||
{#if !data.isPro}
|
||||
<a
|
||||
href="/profile"
|
||||
title="Upgrade to Pro to read in {label}"
|
||||
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)/50 cursor-pointer"
|
||||
>
|
||||
<svg class="w-2.5 h-2.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{label}
|
||||
</a>
|
||||
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
<span class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
||||
<svg class="w-2.5 h-2.5 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>
|
||||
{label}
|
||||
</span>
|
||||
{:else if currentLang() === code}
|
||||
<a href={langUrl(code)} class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)">{label}</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => requestTranslation(code)}
|
||||
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>{label}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if !data.isPro}
|
||||
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-0.5">Pro</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Audio player (hidden in focus mode) -->
|
||||
<!-- ── Audio section (collapsible, hidden in focus/preview mode) ──────────── -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated: sign-in prompt -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
||||
</div>
|
||||
<a href="/login" class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{:else if audioProRequired}
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
|
||||
{:else if audioProRequired}
|
||||
<div class="mb-6 px-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">Upgrade to Pro for unlimited audio.</p>
|
||||
</div>
|
||||
<a href="/profile" class="shrink-0 px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/profile"
|
||||
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
{:else}
|
||||
<!-- Collapsible audio panel -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioExpanded = !audioExpanded; }}
|
||||
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) transition-colors hover:border-(--color-brand)/40
|
||||
{audioExpanded ? 'rounded-t-lg' : 'rounded-lg'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a8 8 0 010-12.728M8.464 15.536a5 5 0 010-7.072" />
|
||||
</svg>
|
||||
<span class="font-medium">Listen to this chapter</span>
|
||||
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying}
|
||||
<span class="text-xs text-(--color-brand)">● Playing</span>
|
||||
{/if}
|
||||
</span>
|
||||
<svg class="w-4 h-4 text-(--color-muted) transition-transform duration-200 {audioExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if audioExpanded}
|
||||
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if data.isPreview}
|
||||
<div class="mb-6 px-4 py-2 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
||||
{m.reader_preview_audio_notice()}
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
||||
{m.reader_preview_audio_notice()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter content -->
|
||||
<!-- ── Chapter content ───────────────────────────────────────────────────── -->
|
||||
{#if fetchingContent}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-(--color-muted) text-sm">
|
||||
<svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -504,7 +507,7 @@
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else if layout.readMode === 'paginated'}
|
||||
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
|
||||
<!-- ── Paginated reader ───────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
@@ -535,11 +538,9 @@
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-(--color-muted) tabular-nums">
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
@@ -552,65 +553,99 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tap hint -->
|
||||
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
|
||||
{:else}
|
||||
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
|
||||
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
|
||||
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
|
||||
{#if !(layout.focusMode && layout.readMode === 'paginated')}
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
||||
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="mt-14 pt-6 border-t border-(--color-border)">
|
||||
<!-- Next chapter: prominent full-width CTA -->
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="group flex items-center justify-between px-5 py-4 rounded-xl bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-(--color-muted) mb-0.5">{m.reader_next_chapter()}</p>
|
||||
<p class="text-(--color-text) font-semibold group-hover:text-(--color-brand) transition-colors">
|
||||
{m.reader_chapter_n({ n: String(data.next) })}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-(--color-muted) group-hover:text-(--color-brand) group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Previous chapter: small secondary link -->
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="mt-3 inline-flex items-center gap-1 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{m.reader_prev_chapter()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
isLoggedIn={!!page.data.user}
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
|
||||
{#if layout.focusMode}
|
||||
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||
>
|
||||
← {m.reader_prev_chapter()}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
{m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', false)}
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
|
||||
aria-label="Exit focus mode"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
Exit focus
|
||||
</button>
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||
>
|
||||
{m.reader_next_chapter()} →
|
||||
{m.reader_chapter_n({ n: String(data.next) })}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
isLoggedIn={!!page.data.user}
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
||||
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
|
||||
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
|
||||
<button
|
||||
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
||||
aria-label="Reader settings"
|
||||
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Bottom sheet -->
|
||||
{#if settingsPanelOpen}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
@@ -648,33 +683,11 @@
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
<!-- ── Typography group ──────────────────────────────────────── -->
|
||||
<!-- ── Typography ──────────────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
|
||||
<div class="flex flex-wrap gap-1.5 flex-1">
|
||||
{#each READER_THEMES as t}
|
||||
<button
|
||||
onclick={() => applyTheme(t.id)}
|
||||
title={t.label}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
|
||||
{panelTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelTheme === t.id}
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -691,7 +704,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -711,12 +723,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Layout group ──────────────────────────────────────────── -->
|
||||
<!-- ── Layout ──────────────────────────────────────────────────── -->
|
||||
<div class="mt-4 mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -734,7 +745,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -752,7 +762,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Width -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -770,7 +779,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraphs -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -788,7 +796,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
@@ -805,12 +812,11 @@
|
||||
|
||||
{:else}
|
||||
|
||||
<!-- ── Listening tab ──────────────────────────────────────────── -->
|
||||
<!-- ── Listening tab ───────────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Player style -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
@@ -828,51 +834,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if page.data.user}
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
|
||||
<div class="flex gap-1 flex-1">
|
||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.speed = s; }}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.speed === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
>
|
||||
<span>Sleep timer</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user