Compare commits

...

17 Commits

Author SHA1 Message Date
Admin
7a2a4fc755 feat(ui): theme system — amber/slate/rose, profile picker, full token migration
Some checks failed
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 25s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 44s
Release / Check ui (push) Successful in 24s
Release / Docker / caddy (push) Failing after 1m4s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Failing after 29s
Release / Docker / runner (push) Failing after 54s
Release / Gitea Release (push) Has been skipped
- Add CSS custom property token system in app.css (@theme + [data-theme] overrides)
- Three themes: amber (default), slate (indigo/dark), rose (dark pink)
- Flash prevention via inline <script> in <svelte:head> sets data-theme before paint
- Theme context (setContext/getContext) in +layout.svelte for live preview
- Theme persisted via PocketBase user_settings (PBUserSettings.theme field)
- /api/settings GET/PUT updated to handle theme field alongside existing settings
- Profile page: new Appearance section with 3 colour-swatch theme picker
- Full token migration across all 36 route/component files:
  zinc/amber hardcoded Tailwind classes → CSS var utilities (bg-(--color-surface), etc.)
- UI primitives (Badge, Button, Card, Dialog, Separator, Textarea) migrated
- accent-amber-400 replaced with inline style accent-color: var(--color-brand)
2026-03-28 23:57:16 +05:00
Admin
801928aadf fix(scraper): update status and genres selectors for current novelfire.net HTML
Some checks failed
CI / Backend (push) Failing after 11s
CI / UI (push) Successful in 48s
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 55s
CI / UI (pull_request) Successful in 37s
Release / Docker / caddy (push) Successful in 47s
CI / Backend (pull_request) Successful in 46s
Release / Docker / runner (push) Successful in 2m37s
Release / Docker / ui (push) Successful in 2m42s
Release / Docker / backend (push) Successful in 3m13s
Release / Gitea Release (push) Failing after 2s
novelfire.net changed its book page structure. Old selectors produced empty
status and null genres for every book, causing all Meilisearch filters to
return zero results.

Old → new:
- status:  <span class="status">  →  <strong class="ongoing|completed|hiatus">
  (text lowercased for consistent index values)
- genres:  <div class="genres"> <a>  →  <div class="categories"> <a class="property-item">
  (text lowercased for consistent index values)

Adds TestParseMetadataSelectors to guard against future regressions.
2026-03-28 22:54:35 +05:00
Admin
040072c3f5 docs(d2): update architecture and api-routing diagrams to current state
All checks were successful
CI / Backend (pull_request) Successful in 33s
CI / UI (pull_request) Successful in 40s
architecture.d2:
- Split app into prod VPS (165.22.70.138) and homelab runner (192.168.0.109)
- Add CrowdSec, Dozzle agent, pocket-tts (voice samples)
- Valkey now shown as Asynq job queue in addition to presign cache
- Add caddy-l4 Redis TCP proxy (:6380) to Caddy label
- Add CI/CD node (Gitea Actions) with full job list incl. releases.json bake
- Remove runner from prod app group (it runs on homelab only)
- Watchtower: note runner is label-disabled on prod

api-routing.d2:
- Add /api/presign/* routes to backend (presign_be group)
- Add /api/audio POST + status GET to both sk and be
- Add /api/scrape/book and /api/scrape/book/range to scrape_sk
- Catalogue: annotate Meilisearch vs legacy browse
- Add Meilisearch filter/sort fields to storage node
- Add Asynq queue note to Valkey storage node
- Fix presign proxy: sk routes through be.presign_be, not directly to storage
2026-03-28 22:44:31 +05:00
Admin
6a76e97a67 fix(ci): follow HTTP redirect and validate JSON in fetch-releases step
Some checks failed
CI / Backend (push) Successful in 26s
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 28s
CI / UI (push) Successful in 1m4s
CI / UI (pull_request) Failing after 11s
CI / Backend (pull_request) Successful in 27s
Release / Docker / caddy (push) Successful in 1m3s
Release / Docker / runner (push) Failing after 1m3s
Release / Docker / ui (push) Successful in 1m53s
Release / Docker / backend (push) Failing after 4m2s
Release / Gitea Release (push) Has been skipped
curl -sf without -L silently wrote '301 Moved Permanently' to releases.json
instead of following the http→https redirect. Added -L to follow redirects,
set -euo pipefail, and jq type validation so the step fails hard on bad JSON.
2026-03-28 22:38:09 +05:00
Admin
71f79c8e02 feat(admin): bake changelog into UI image at CI build time
Some checks failed
CI / Backend (push) Failing after 11s
Release / Test backend (push) Successful in 34s
CI / UI (push) Successful in 48s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 52s
CI / Backend (pull_request) Successful in 42s
CI / UI (pull_request) Successful in 37s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Successful in 2m24s
Release / Docker / runner (push) Failing after 3m24s
Release / Gitea Release (push) Has been skipped
Replace runtime Gitea API fetch with fs.readFileSync of releases.json,
which CI writes to ui/static/ before the Docker build context is sent.
Eliminates prod→homelab network dependency for the changelog page.
2026-03-28 21:49:52 +05:00
Admin
5ee4a06654 feat(admin): compact scrape page layout; add Changelog page
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 35s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 36s
CI / Backend (pull_request) Successful in 41s
Release / Docker / caddy (push) Successful in 1m31s
CI / UI (pull_request) Successful in 49s
Release / Docker / backend (push) Failing after 1m9s
Release / Docker / ui (push) Successful in 2m18s
Release / Docker / runner (push) Successful in 3m49s
Release / Gitea Release (push) Has been skipped
- Scrape page: replace three large cards + genre card with a compact
  bordered table of rows (label + inline controls per action). Visually
  much lighter, all controls visible without scrolling.
- Admin sidebar: add Changelog link after Audio.
- New /admin/changelog page: fetches releases from Gitea API and renders
  them as a clean list (tag, title, date, body).
2026-03-28 21:41:13 +05:00
Admin
63b286d0a4 fix(caddy): move layer4 into global block; use :6380 listener address
Some checks failed
CI / Backend (push) Successful in 30s
CI / UI (push) Successful in 39s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 25s
CI / UI (pull_request) Successful in 25s
Release / Docker / caddy (push) Successful in 1m9s
CI / Backend (pull_request) Successful in 1m14s
Release / Docker / ui (push) Successful in 3m56s
Release / Docker / runner (push) Successful in 4m41s
Release / Docker / backend (push) Successful in 7m51s
Release / Gitea Release (push) Failing after 2s
The bare { } block at the bottom was a second global options block which
Caddy's caddyfile adapter rejects on reload. Merged layer4 into the single
top-level global block. Changed listener from hostname (redis.libnovel.cc:6380)
to :6380 so Caddy binds to the local interface rather than the Cloudflare IP
that resolves for the hostname.
2026-03-28 21:36:12 +05:00
Admin
d3f06c5c40 fix(caddy): add 404 error page; add health checks and lb_try_duration to ui upstream
Some checks failed
Release / Test backend (push) Successful in 26s
CI / Backend (push) Successful in 42s
CI / UI (push) Successful in 47s
Release / Check ui (push) Successful in 26s
CI / UI (pull_request) Successful in 26s
CI / Backend (pull_request) Successful in 44s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Failing after 1m4s
Release / Docker / runner (push) Failing after 1m3s
Release / Docker / ui (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped
- Add caddy/errors/404.html (matches existing 502/503/504 style)
- Add handle_errors 404 block in Caddyfile
- Add active health checks (5s interval) and lb_try_duration 3s to the
  ui reverse_proxy so Caddy detects Watchtower container replacements
  quickly and serves the 502 maintenance page instead of a raw error
2026-03-28 21:32:04 +05:00
Admin
e71ddc2f8b fix(backend): add ffmpeg to backend image for pocket-tts voice sample generation
Some checks failed
CI / Backend (push) Successful in 29s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 37s
CI / Backend (pull_request) Failing after 11s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 57s
CI / UI (pull_request) Successful in 56s
Release / Docker / runner (push) Failing after 1m22s
Release / Docker / backend (push) Failing after 1m46s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Has been skipped
handlePresignVoiceSample generates voice samples on demand via pocket-tts,
which requires WAV→MP3 transcoding via ffmpeg. The backend was using
distroless/static (no ffmpeg) so all pocket-tts preview requests returned 500.
Switch backend stage to Alpine + ffmpeg, matching the runner image.
2026-03-28 21:24:59 +05:00
Admin
b783dae5f4 refactor(admin): replace tab bar with sidebar layout
Some checks failed
CI / Backend (push) Successful in 38s
CI / UI (push) Successful in 42s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 28s
Release / Docker / caddy (push) Successful in 1m11s
CI / UI (pull_request) Successful in 29s
Release / Docker / ui (push) Successful in 1m54s
Release / Docker / runner (push) Successful in 4m27s
Release / Docker / backend (push) Failing after 5m11s
Release / Gitea Release (push) Has been skipped
Move admin navigation from a two-row tab strip into a persistent left
sidebar with grouped sections (Pages / Tools). Consolidate Scrape and
Audio entries in the global top nav into a single Admin link.
2026-03-28 21:15:49 +05:00
Admin
dcf40197d4 fix(ui): brighten footer link text for readability on dark background
Some checks failed
CI / Backend (push) Successful in 51s
CI / UI (push) Successful in 28s
Release / Test backend (push) Successful in 50s
Release / Docker / caddy (push) Successful in 1m0s
Release / Check ui (push) Successful in 1m15s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 32s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Successful in 4m9s
Release / Docker / runner (push) Successful in 5m46s
Release / Gitea Release (push) Failing after 1s
2026-03-28 21:12:46 +05:00
Admin
9dae5e7cc0 fix(infra): add POCKET_TTS_URL to backend and runner services
Some checks failed
CI / Backend (push) Successful in 27s
Release / Test backend (push) Successful in 39s
CI / UI (push) Successful in 48s
Release / Check ui (push) Successful in 25s
CI / UI (pull_request) Successful in 25s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m7s
Release / Docker / backend (push) Successful in 2m17s
Release / Docker / ui (push) Successful in 2m9s
Release / Docker / runner (push) Failing after 3m34s
Release / Gitea Release (push) Has been skipped
Backend was missing POCKET_TTS_URL entirely — pocketTTSClient was nil
so voices() only returned 67 Kokoro voices. Runner already had the var
via Doppler but it was absent from the compose environment block.

Also fix stray leading space on backend environment: key (YAML parse error).

Verified: /api/voices now returns 87 voices (67 kokoro + 20 pocket-tts).
2026-03-28 20:54:04 +05:00
Admin
908f5679fd fix(ui): defer catalogue filter navigation to explicit Apply button
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Successful in 34s
Release / Test backend (push) Successful in 52s
CI / UI (push) Successful in 55s
Release / Docker / caddy (push) Successful in 48s
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 43s
Release / Docker / runner (push) Failing after 38s
Release / Docker / ui (push) Successful in 1m55s
Release / Docker / backend (push) Successful in 3m30s
Release / Gitea Release (push) Has been skipped
Removed onchange→navigateWithFilters from all three selects — the
immediate navigation on every change was the root cause of both bugs:
1. Filters applied before the user finished selecting all options.
2. Svelte 5 bind:value updates state after onchange fires, so the
   navigateWithFilters call read stale values → wrong URL params → no results.

Renamed navigateWithFilters to applyFilters (no overrides arg needed).
Added an amber Apply button next to Reset; selects now only update local
state until the user presses Apply.
2026-03-28 19:44:36 +05:00
Admin
f75292f531 fix(homelab): add Google + GitHub OAuth env vars to Fider service
All checks were successful
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 38s
2026-03-28 19:38:11 +05:00
Admin
2cf0528730 fix(ui): show version+SHA+build time in footer; fix env not reaching runtime image
Some checks failed
CI / Backend (push) Successful in 51s
CI / UI (push) Successful in 27s
Release / Docker / caddy (push) Failing after 23s
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 39s
CI / Backend (pull_request) Successful in 28s
CI / UI (pull_request) Successful in 36s
Release / Docker / backend (push) Failing after 1m25s
Release / Docker / runner (push) Successful in 2m25s
Release / Docker / ui (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
PUBLIC_BUILD_VERSION and PUBLIC_BUILD_COMMIT were set only in the builder
stage ENV — they were never re-declared in the runtime stage, so the Node
server started with them undefined and the badge always showed 'dev'.

Fix: re-declare all three ARGs after the second FROM and set runtime ENVs.
Add PUBLIC_BUILD_TIME (ISO timestamp from gitea.event.head_commit.timestamp)
injected via build-arg in release.yaml. Badge now shows e.g.:
  v2.3.9+abc1234 · 28 Mar 2026 14:30 UTC
2026-03-28 19:33:49 +05:00
Admin
428b57732e fix(ui): resolve avatar URL from MinIO; fall back to OAuth provider URL
Some checks failed
CI / Backend (push) Successful in 50s
CI / UI (push) Successful in 34s
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 35s
CI / Backend (pull_request) Successful in 49s
CI / UI (pull_request) Successful in 40s
Release / Docker / backend (push) Failing after 52s
Release / Docker / runner (push) Failing after 58s
Release / Docker / ui (push) Failing after 55s
Release / Gitea Release (push) Has been skipped
Add resolveAvatarUrl(userId, storedValue) helper that tries MinIO first,
then falls back to the stored HTTP URL for OAuth users (Google/GitHub)
who have never uploaded a custom avatar.

Add getUserById() to pocketbase helpers for batch avatar resolution in
comments. Update all 6 call sites to use the new helper.
2026-03-28 19:23:30 +05:00
Admin
61e77e3e28 ci: remove Docker builds from CI; keep vet/build/test/type-check only
All checks were successful
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 37s
CI / UI (pull_request) Successful in 35s
CI / Backend (pull_request) Successful in 1m9s
Docker image builds belong to the release workflow (tag-triggered).
CI now runs: go vet, go build (backend/runner/healthcheck), go test,
svelte-check, and UI vite build — fast feedback without Docker overhead.
Also triggers on all branches, not just main/master.
2026-03-28 19:08:17 +05:00
59 changed files with 1660 additions and 1271 deletions

View File

@@ -2,20 +2,14 @@ name: CI
on:
push:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
pull_request:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
concurrency:
@@ -23,10 +17,13 @@ concurrency:
cancel-in-progress: true
jobs:
# ── backend: vet & test ───────────────────────────────────────────────────────
test-backend:
name: Test backend
# ── Go: vet + build + test ────────────────────────────────────────────────
backend:
name: Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
@@ -36,16 +33,23 @@ jobs:
cache-dependency-path: backend/go.sum
- name: go vet
working-directory: backend
run: go vet ./...
- name: Build backend
run: go build -o /dev/null ./cmd/backend
- name: Build runner
run: go build -o /dev/null ./cmd/runner
- name: Build healthcheck
run: go build -o /dev/null ./cmd/healthcheck
- name: Run tests
working-directory: backend
run: go test -short -race -count=1 -timeout=60s ./...
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
# ── UI: type-check + build ────────────────────────────────────────────────
ui:
name: UI
runs-on: ubuntu-latest
defaults:
run:
@@ -67,57 +71,3 @@ jobs:
- name: Build
run: npm run build
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
docker-backend:
name: Docker / backend
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: false
docker-runner:
name: Docker / runner
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: false
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: ui
push: false
docker-caddy:
name: Docker / caddy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: caddy
push: false

View File

@@ -190,6 +190,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Fetch releases from Gitea API
run: |
set -euo pipefail
RESPONSE=$(curl -sfL \
-H "Accept: application/json" \
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
# Validate JSON before writing — fails hard if response is not a JSON array
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
echo "$RESPONSE" > ui/static/releases.json
echo "Fetched $COUNT releases"
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -218,6 +229,7 @@ jobs:
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline

View File

@@ -56,6 +56,22 @@
ticker_interval 15s
}
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
# Exposes homelab Redis over TLS for Asynq job enqueueing from the backend.
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
# for redis.libnovel.cc; traffic is proxied to the homelab Redis instance.
# Requires the caddy-l4 module in the custom Caddy build.
layer4 {
:6380 {
route {
tls
proxy {
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
}
}
}
}
}
(security_headers) {
header {
@@ -170,12 +186,31 @@
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
handle {
reverse_proxy ui:3000 {
}
# Active health check: Caddy polls /health every 5 s and marks the
# upstream down immediately when it fails. Combined with
# lb_try_duration this means Watchtower container replacements
# show the maintenance page within a few seconds instead of
# hanging or returning a raw connection error to the browser.
health_uri /health
health_interval 5s
health_timeout 2s
health_status 200
# If the upstream is down, fail fast (don't retry for longer than
# 3 s) and let Caddy's handle_errors 502/503 take over.
lb_try_duration 3s
}
}
# ── Caddy-level error pages ───────────────────────────────────────────────
# These fire when the upstream (backend or ui) is completely unreachable.
# SvelteKit's own +error.svelte handles application-level errors (404, 500).
handle_errors 404 {
root * /srv/errors
rewrite * /404.html
file_server
}
handle_errors 502 {
root * /srv/errors
rewrite * /502.html
file_server
@@ -234,27 +269,3 @@ search.libnovel.cc {
reverse_proxy meilisearch:7700
}
}
# ── Redis TCP proxy: exposes homelab Redis over TLS for Asynq ─────────────────
# The backend (prod) connects to rediss://redis.libnovel.cc:6380 to enqueue
# Asynq jobs. Caddy terminates TLS (Let's Encrypt cert for redis.libnovel.cc)
# and proxies the raw TCP stream to the homelab Redis via this reverse proxy.
#
# NOTE: Redis is NOT running on the prod server — it runs on the homelab
# (192.168.0.109:6379) and is exposed to the internet via this Caddy proxy.
# The homelab Redis is protected by REDIS_PASSWORD (requirepass).
#
# Caddy layer4 app handles this; requires the caddy-l4 module in the build.
{
layer4 {
redis.libnovel.cc:6380 {
route {
tls
proxy {
# Homelab Redis — replace with actual homelab IP or FQDN
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
}
}
}
}
}
}

View File

@@ -30,9 +30,14 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
-o /out/healthcheck ./cmd/healthcheck
# ── backend service ──────────────────────────────────────────────────────────
FROM gcr.io/distroless/static:nonroot AS backend
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
# sample generation via pocket-tts (WAV→MP3 transcoding).
FROM alpine:3.21 AS backend
RUN apk add --no-cache ffmpeg ca-certificates && \
addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /out/healthcheck /healthcheck
COPY --from=builder /out/backend /backend
USER appuser
ENTRYPOINT ["/backend"]
# ── runner service ───────────────────────────────────────────────────────────

View File

@@ -178,12 +178,26 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.Bo
}
}
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
// Status: novelfire renders <strong class="ongoing">Ongoing</strong> (or
// "completed", "hiatus") inside the .header-stats block. We take the text
// content and lowercase it so the index value is always canonical lowercase.
var status string
for _, cls := range []string{"ongoing", "completed", "hiatus"} {
if v := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "strong", Class: cls}); v != "" {
status = strings.ToLower(strings.TrimSpace(v))
break
}
}
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
// Genres: novelfire renders <div class="categories"><ul><li><a class="property-item">Genre</a>
// Each <a class="property-item"> is one genre tag. Lowercase for index consistency.
var genres []string
if genresNode != nil {
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
if categoriesNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "categories"}); categoriesNode != nil {
for _, v := range htmlutil.ExtractAll(categoriesNode, scraper.Selector{Tag: "a", Class: "property-item", Multiple: true}) {
if v != "" {
genres = append(genres, strings.ToLower(strings.TrimSpace(v)))
}
}
}
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})

View File

@@ -2,6 +2,7 @@ package novelfire
import (
"context"
"log/slog"
"testing"
)
@@ -100,6 +101,56 @@ func TestRetryGet_EventualSuccess(t *testing.T) {
}
}
// TestParseMetadataSelectors verifies that the status and genres selectors
// match the current novelfire.net HTML structure.
func TestParseMetadataSelectors(t *testing.T) {
// Minimal HTML reproducing the relevant novelfire.net book page structure.
const html = `<!DOCTYPE html>
<html><body>
<h1 class="novel-title">Shadow Slave</h1>
<span class="author">Guiltythree</span>
<figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure>
<div class="header-stats">
<span><strong>123</strong><small>Chapters</small></span>
<span> <strong class="ongoing">Ongoing</strong> <small>Status</small></span>
</div>
<div class="categories">
<h4>Genres</h4>
<ul>
<li><a href="/genre-fantasy/..." class="property-item">Fantasy</a></li>
<li><a href="/genre-action/..." class="property-item">Action</a></li>
<li><a href="/genre-adventure/..." class="property-item">Adventure</a></li>
</ul>
</div>
<span class="chapter-count">123 Chapters</span>
</body></html>`
stub := newStubClient()
stub.setFn("https://novelfire.net/book/shadow-slave", func() (string, error) {
return html, nil
})
s := &Scraper{client: stub, log: slog.Default()}
meta, err := s.ScrapeMetadata(t.Context(), "https://novelfire.net/book/shadow-slave")
if err != nil {
t.Fatalf("ScrapeMetadata: %v", err)
}
if meta.Status != "ongoing" {
t.Errorf("status = %q, want %q", meta.Status, "ongoing")
}
wantGenres := []string{"fantasy", "action", "adventure"}
if len(meta.Genres) != len(wantGenres) {
t.Fatalf("genres = %v, want %v", meta.Genres, wantGenres)
}
for i, g := range meta.Genres {
if g != wantGenres[i] {
t.Errorf("genres[%d] = %q, want %q", i, g, wantGenres[i])
}
}
}
// ── minimal stub client for tests ─────────────────────────────────────────────
type stubClient struct {

51
caddy/errors/404.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — Page Not Found</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
text-align: center;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
font-weight: 800;
color: #27272a;
line-height: 1;
letter-spacing: -0.04em;
}
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
a {
margin-top: 0.5rem;
display: inline-block;
padding: 0.6rem 1.4rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
}
a:hover { background: #d97706; }
</style>
</head>
<body>
<div class="code">404</div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a href="/">Go home</a>
</body>
</html>

View File

@@ -154,7 +154,7 @@ services:
# No public port — all traffic is routed via Caddy.
expose:
- "8080"
environment:
environment:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
LOG_LEVEL: "${LOG_LEVEL}"
@@ -224,6 +224,7 @@ services:
# Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "runner"

View File

@@ -35,11 +35,11 @@ client: Browser / iOS App {
caddy: Caddy :443 {
shape: rectangle
style.fill: "#f1f5f9"
label: "Caddy :443\ncustom build · caddy-ratelimit\nsecurity headers · rate limiting\nstatic error pages"
label: "Caddy :443\ncustom build · caddy-l4 · caddy-ratelimit\nCrowdSec bouncer · security headers\nrate limiting · static error pages\nRedis TCP proxy :6380"
}
# ─── SvelteKit UI ─────────────────────────────────────────────────────────────
# Handles: auth enforcement, session, all /api/* routes that have SK counterparts
# All routes here pass through SvelteKit — auth is enforced server-side.
sk: SvelteKit UI :3000 {
style.fill: "#fef3c7"
@@ -53,7 +53,7 @@ sk: SvelteKit UI :3000 {
catalogue_sk: Catalogue {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/catalogue-page\nGET /api/search"
label: "GET /api/catalogue-page (infinite scroll)\nGET /api/search"
}
book_sk: Book {
@@ -65,7 +65,7 @@ sk: SvelteKit UI :3000 {
scrape_sk: Scrape (admin) {
style.fill: "#fff7ed"
style.stroke: "#fdba74"
label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/range\nPOST /api/scrape/cancel/{id}"
label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/book\nPOST /api/scrape/book/range\nPOST /api/scrape/cancel/{id}"
}
audio_sk: Audio {
@@ -74,7 +74,7 @@ sk: SvelteKit UI :3000 {
label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/voices"
}
presign_sk: Presigned URLs {
presign_sk: Presigned URLs (public) {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}"
@@ -106,12 +106,12 @@ sk: SvelteKit UI :3000 {
}
# ─── Go Backend ───────────────────────────────────────────────────────────────
# Caddy proxies these paths directly — no SvelteKit auth layer
# Caddy proxies these paths directly — bypasses SvelteKit entirely.
be: Backend API :8080 {
style.fill: "#eef3ff"
health_be: Health {
health_be: Health / Version {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /health\nGET /api/version"
@@ -126,7 +126,7 @@ be: Backend API :8080 {
catalogue_be: Catalogue {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/browse\nGET /api/catalogue\nGET /api/ranking\nGET /api/cover/{domain}/{slug}"
label: "GET /api/catalogue (Meilisearch)\nGET /api/browse (legacy MinIO cache)\nGET /api/ranking\nGET /api/cover/{domain}/{slug}"
}
book_be: Book / Chapter {
@@ -138,7 +138,13 @@ be: Backend API :8080 {
audio_be: Audio {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/audio-proxy/{slug}/{n}\nGET /api/voices"
label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/audio-proxy/{slug}/{n}\nGET /api/voices"
}
presign_be: Presigned URLs {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}\nGET /api/presign/avatar-upload/{userId}\nGET /api/presign/avatar/{userId}"
}
}
@@ -149,19 +155,19 @@ storage: Storage {
pb: PocketBase :8090 {
shape: cylinder
label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache"
label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache\nranking"
}
mn: MinIO :9000 {
shape: cylinder
label: "chapters · audio\navatars · browse"
label: "chapters · audio\navatars · catalogue (browse)"
}
ms: Meilisearch :7700 {
shape: cylinder
label: "index: books"
label: "index: books\nfilterable: status · genres\nsortable: rank · rating\n total_chapters · meta_updated"
}
vk: Valkey :6379 {
shape: cylinder
label: "presign URL cache"
label: "presign URL cache (TTL ~55 min)\nAsynq job queue (runner)"
}
}
@@ -169,18 +175,17 @@ storage: Storage {
client -> caddy: HTTPS :443
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth"
caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking"
caddy -> storage.mn: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
caddy -> sk: "/* (catch-all)\n→ SvelteKit enforces auth"
caddy -> be: "/health /scrape*\n/api/browse /api/catalogue /api/ranking\n/api/version /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio* /api/voices /api/presign/*"
caddy -> storage.mn: "/avatars/* /audio/* /chapters/*\n(presigned MinIO GETs)"
# ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
sk.catalogue_sk -> be.catalogue_be: internal proxy
sk.book_sk -> be.book_be: internal proxy
sk.audio_sk -> be.audio_be: internal proxy
sk.presign_sk -> storage.vk: check cache
sk.presign_sk -> storage.mn: generate presign
sk.presign_user -> storage.mn: generate presign
sk.presign_sk -> be.presign_be: internal proxy
sk.presign_user -> be.presign_be: internal proxy
# ─── SvelteKit → Storage (direct) ────────────────────────────────────────────
@@ -192,10 +197,12 @@ sk.comments_sk -> storage.pb
# ─── Backend → Storage ────────────────────────────────────────────────────────
be.catalogue_be -> storage.ms: full-text search
be.catalogue_be -> storage.ms: full-text search + facets
be.catalogue_be -> storage.pb: ranking records
be.catalogue_be -> storage.mn: cover presign
be.book_be -> storage.mn: chapter objects
be.book_be -> storage.pb: book metadata
be.audio_be -> storage.mn: audio presign
be.audio_be -> storage.vk: presign cache
be.presign_be -> storage.vk: check / set presign cache
be.presign_be -> storage.mn: generate presigned URL

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -5,16 +5,25 @@ direction: right
novelfire: novelfire.net {
shape: cloud
style.fill: "#f0f4ff"
label: "novelfire.net\n(scrape source)"
}
kokoro: Kokoro-FastAPI TTS {
shape: cloud
style.fill: "#f0f4ff"
label: "Kokoro-FastAPI TTS\n(self-hosted · homelab)\nchapter audio"
}
pockettts: pocket-tts {
shape: cloud
style.fill: "#f0f4ff"
label: "pocket-tts\n(self-hosted · homelab)\nvoice sample MP3s"
}
letsencrypt: Let's Encrypt {
shape: cloud
style.fill: "#f0f4ff"
label: "Let's Encrypt\n(ACME TLS-ALPN-01)"
}
browser: Browser / iOS App {
@@ -30,12 +39,12 @@ init: Init containers {
minio-init: minio-init {
shape: rectangle
label: "minio-init\n(mc: create buckets)"
label: "minio-init\n(mc: create buckets\n chapters · audio\n avatars · catalogue)"
}
pb-init: pb-init {
shape: rectangle
label: "pb-init\n(bootstrap collections)"
label: "pb-init\n(bootstrap PocketBase\n collections + schema)"
}
}
@@ -46,109 +55,126 @@ storage: Storage {
minio: MinIO {
shape: cylinder
label: "MinIO :9000\n\nbuckets:\n chapters\n audio\n avatars\n catalogue"
label: "MinIO :9000\nbuckets:\n chapters · audio\n avatars · catalogue"
}
pocketbase: PocketBase {
shape: cylinder
label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking"
label: "PocketBase :8090\ncollections:\n books · chapters_idx\n audio_cache · progress\n scrape_jobs · app_users\n ranking · library\n comments"
}
valkey: Valkey {
shape: cylinder
label: "Valkey :6379\n\n(presign URL cache\nTTL-based, shared)"
label: "Valkey :6379\npresign URL cache (TTL ~55 min)\nAsynq job queue (runner tasks)"
}
meilisearch: Meilisearch {
shape: cylinder
label: "Meilisearch :7700\n\nindices:\n books"
label: "Meilisearch :7700\nindex: books\n(filterable: status · genres\n sortable: rank · rating\n total_chapters · meta_updated)"
}
}
# ─── Application ──────────────────────────────────────────────────────────────
# ─── Application — prod VPS (165.22.70.138) ───────────────────────────────────
app: Application {
app: Application — prod (165.22.70.138) {
style.fill: "#eef3ff"
caddy: caddy {
shape: rectangle
label: "Caddy :443 / :80\ncustom build + caddy-ratelimit\n\nfeatures:\n auto-HTTPS (Let's Encrypt)\n security headers\n rate limiting (per-IP)\n static error pages (502/503/504)"
label: "Caddy :443 / :80 / :6380\ncustom build\n+ caddy-l4 (Redis TCP proxy)\n+ caddy-ratelimit\nauto-HTTPS · security headers\nrate limiting (per-IP)\nstatic error pages (404/502/503/504)\nCrowdSec bouncer"
}
backend: backend {
shape: rectangle
label: "Backend API :8080\n(GoHTTP API server)"
}
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go — background worker\nscraping + TTS jobs\n/metrics endpoint)"
label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors"
}
ui: ui {
shape: rectangle
label: "SvelteKit UI :3000\n(adapter-node)"
label: "SvelteKit UI :3000\n(adapter-node)\nSSR · session auth\nserver-side API proxy"
}
crowdsec: CrowdSec {
shape: rectangle
label: "CrowdSec :8080\nsecurity engine\nreads Caddy JSON logs\nbouncer integrated in Caddy"
}
dozzle: Dozzle agent {
shape: rectangle
label: "Dozzle agent\n127.0.0.1:7007\nlog relay → homelab dashboard"
}
}
# ─── Runner — homelab (192.168.0.109) ────────────────────────────────────────
homelab: Runner — homelab (192.168.0.109) {
style.fill: "#fef9ec"
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go background worker)\nscrape pipeline\nTTS audio job queue\nPrometheus /metrics\ncron: catalogue refresh\nAsynq worker → Valkey"
}
}
# ─── Ops ──────────────────────────────────────────────────────────────────────
ops: Ops {
style.fill: "#fef9ec"
style.fill: "#f5f5f5"
watchtower: Watchtower {
shape: rectangle
label: "Watchtower\n(containrrr/watchtower)\n\npolls every 5 min\nautopulls + redeploys:\n backend · runner · ui"
label: "Watchtower\n(containrrr/watchtower)\npolls Docker Hub every 5 min\nautopulls + redeploys:\n backend · ui\n(runner: label-disabled on prod)"
}
}
# ─── Init → Storage deps ──────────────────────────────────────────────────────
# ─── CI / CD ──────────────────────────────────────────────────────────────────
cicd: CI / CD {
style.fill: "#f0f9ff"
gitea: Gitea Actions {
shape: rectangle
label: "Gitea Actions\n(homelab runner)\ntag v* trigger:\n test-backend\n check-ui (type-check + build)\n docker-backend\n docker-runner\n docker-ui (bakes releases.json)\n docker-caddy\n → push Docker Hub\n → Gitea Release"
}
}
# ─── Init → Storage ───────────────────────────────────────────────────────────
init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}
# ─── App → Storage ────────────────────────────────────────────────────────────
app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse)
app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…)
app.backend -> storage.valkey: cache presigned URLs\n(SET/GET with TTL)
app.runner -> storage.minio: write chapter markdown\n& audio MP3s
app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records
app.runner -> storage.meilisearch: index books on\nscrape completion
app.ui -> storage.valkey: read presigned URL cache
app.ui -> storage.pocketbase: auth, progress,\ncomments, settings
# ─── App internal ─────────────────────────────────────────────────────────────
app.ui -> app.backend: REST API calls (server-side)\n/api/catalogue /api/book-preview\n/api/chapter-text /api/audio etc.
app.caddy -> app.ui: "/* (catch-all)\nSvelteKit — auth enforced"
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/catalogue\n/api/ranking /api/version\n/api/book-preview/*\n/api/chapter-text/*\n/api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/voices\n/api/audio* /api/presign/*"
app.caddy -> storage.minio: "/avatars/* /audio/*\n/chapters/*\n(presigned GETs)"
app.caddy -> app.crowdsec: bouncer check (15 s poll)
app.caddy -> letsencrypt: ACME cert (TLS-ALPN-01)
# ─── Caddy routing ────────────────────────────────────────────────────────────
# Routes sent directly to backend (no SvelteKit counterpart):
# /health /scrape*
# /api/browse /api/book-preview/* /api/chapter-text/*
# /api/reindex/* /api/cover/* /api/audio-proxy/*
# Routes sent to MinIO:
# /avatars/*
# Everything else → SvelteKit UI (including /api/scrape/*, /api/chapter-text-preview/*)
app.ui -> app.backend: "internal REST proxy\n(server-side only)"
app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments"
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)"
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/*"
app.caddy -> storage.minio: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache"
app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking"
app.backend -> storage.valkey: "presign URL cache\n(SET/GET TTL ~55 min)"
app.backend -> storage.meilisearch: "catalogue search\nfacets: genres · status"
app.backend -> pockettts: "voice sample gen.\n(on-demand · ffmpeg conv.)"
# ─── External → App ───────────────────────────────────────────────────────────
# ─── Runner → deps ────────────────────────────────────────────────────────────
app.runner -> novelfire: scrape\n(HTTP GET)
app.runner -> kokoro: TTS generation\n(HTTP POST)
app.caddy -> letsencrypt: ACME certificate\n(TLS-ALPN-01)
homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown"
homelab.runner -> kokoro: "TTS generation\ntext → MP3"
homelab.runner -> storage.minio: "write chapters\n& audio MP3s"
homelab.runner -> storage.pocketbase: "read/update scrape_jobs\nwrite book records"
homelab.runner -> storage.meilisearch: "index books\n(on scrape completion)"
homelab.runner -> storage.valkey: "Asynq job queue\n(task consume)"
# ─── Ops → Docker socket ──────────────────────────────────────────────────────
ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.runner: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
# ─── Browser ──────────────────────────────────────────────────────────────────
# ─── Client ───────────────────────────────────────────────────────────────────
browser -> app.caddy: HTTPS :443\n(single entry point)
# ─── Ops / CI ─────────────────────────────────────────────────────────────────
ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
cicd.gitea -> ops.watchtower: push to Docker Hub\n→ Watchtower detects new tag

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -222,6 +222,10 @@ services:
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
EMAIL_SMTP_ENABLE_STARTTLS: "false"
OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}"
# ── Dozzle ──────────────────────────────────────────────────────────────────
# Watches both homelab and prod containers.

3
ui/.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Generated by CI at build time — do not commit
/static/releases.json

View File

@@ -14,10 +14,12 @@ COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Carry build-time metadata into the runtime image so the UI footer can
# display the version, commit SHA, and build timestamp.
# These must be re-declared after the second FROM — ARG values do not
# cross stage boundaries, but ENV values set here persist at runtime.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
EXPOSE $PORT
CMD ["node", "build"]

View File

@@ -8,6 +8,47 @@
--color-surface-3: #3f3f46; /* zinc-700 */
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f3f46; /* zinc-700 */
--color-danger: #f87171; /* red-400 */
}
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
[data-theme="amber"] {
--color-brand: #f59e0b;
--color-brand-dim: #d97706;
--color-surface: #18181b;
--color-surface-2: #27272a;
--color-surface-3: #3f3f46;
--color-muted: #a1a1aa;
--color-text: #f4f4f5;
--color-border: #3f3f46;
--color-danger: #f87171;
}
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
[data-theme="slate"] {
--color-brand: #818cf8; /* indigo-400 */
--color-brand-dim: #4f46e5; /* indigo-600 */
--color-surface: #0f172a; /* slate-900 */
--color-surface-2: #1e293b; /* slate-800 */
--color-surface-3: #334155; /* slate-700 */
--color-muted: #94a3b8; /* slate-400 */
--color-text: #f1f5f9; /* slate-100 */
--color-border: #334155; /* slate-700 */
--color-danger: #f87171; /* red-400 */
}
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
[data-theme="rose"] {
--color-brand: #fb7185; /* rose-400 */
--color-brand-dim: #e11d48; /* rose-600 */
--color-surface: #18181b; /* zinc-900 */
--color-surface-2: #1c1318; /* custom dark rose */
--color-surface-3: #2d1f26; /* custom dark rose-2 */
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f2d36; /* custom rose border */
--color-danger: #f87171; /* red-400 */
}
html {
@@ -20,13 +61,13 @@ html {
max-width: 72ch;
line-height: 1.85;
font-size: 1.05rem;
color: #d4d4d8; /* zinc-300 */
color: var(--color-muted);
}
.prose-chapter h1,
.prose-chapter h2,
.prose-chapter h3 {
color: #f4f4f5;
color: var(--color-text);
font-weight: 700;
margin-top: 1.5em;
margin-bottom: 0.5em;
@@ -41,15 +82,15 @@ html {
}
.prose-chapter em {
color: #a1a1aa;
color: var(--color-muted);
}
.prose-chapter strong {
color: #f4f4f5;
color: var(--color-text);
}
.prose-chapter hr {
border-color: #3f3f46;
border-color: var(--color-border);
margin: 2em 0;
}
@@ -62,4 +103,3 @@ html {
.animate-progress-bar {
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}

View File

@@ -675,7 +675,7 @@
<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
{#snippet voiceRow(v: import('$lib/types').Voice)}
<div
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-amber-400/10')}
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v.id)}
@@ -684,23 +684,23 @@
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v.id}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v.id}</span>
<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>
<!-- Sample play button -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
@@ -718,13 +718,13 @@
</div>
{/snippet}
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
<span class="text-sm text-(--color-text) font-medium">Audio Narration</span>
</div>
<!-- Voice selector button -->
@@ -733,7 +733,7 @@
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title="Change voice"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
@@ -749,13 +749,13 @@
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
{#if showVoicePanel && voices.length > 0}
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Choose Voice</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
onclick={() => { stopSample(); showVoicePanel = false; }}
aria-label="Close voice selector"
>
@@ -767,8 +767,8 @@
<div class="max-h-64 overflow-y-auto">
<!-- Kokoro (GPU) section -->
{#if kokoroVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
</div>
{#each kokoroVoices as v (v.id)}
{@render voiceRow(v)}
@@ -777,21 +777,21 @@
<!-- Pocket TTS (CPU) section -->
{#if pocketVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50 {kokoroVoices.length > 0 ? 'border-t border-zinc-700' : ''}">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Pocket TTS (CPU)</span>
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
</div>
{#each pocketVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
</div>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500">
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">
New voice applies on next "Play narration".
{#if voices.length > 0}
<a
href="/api/audio/voice-samples"
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
onclick={(e) => {
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
@@ -808,7 +808,7 @@
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
{#if audioStore.status === 'error'}
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
@@ -828,22 +828,22 @@
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
<p class="text-xs text-zinc-400">Generating narration…</p>
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<p class="text-xs text-(--color-muted)">Generating narration…</p>
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
<div
class="h-full bg-amber-400 rounded-full transition-none"
class="h-full bg-(--color-brand) rounded-full transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
<p class="text-xs text-(--color-muted) opacity-60 tabular-nums">{Math.round(audioStore.progress)}%</p>
</div>
{:else if audioStore.status === 'ready'}
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400">
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
@@ -853,7 +853,7 @@
</svg>
<span>Paused — controls below</span>
{/if}
<span class="tabular-nums text-zinc-500">
<span class="tabular-nums text-(--color-muted) opacity-60">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
@@ -863,7 +863,7 @@
<Button
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')}
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 ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
@@ -879,24 +879,24 @@
<!-- 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-zinc-500">
<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>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 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>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
{/if}
{#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>Preparing Ch.{nextChapter}{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>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">Ch.{nextChapter} will generate on navigate</p>
{/if}
</div>
{/if}
{/if}
@@ -904,7 +904,7 @@
{:else if audioStore.active}
<!-- ── A different chapter is currently playing ── -->
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-zinc-400">
<p class="text-xs text-(--color-muted)">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>

View File

@@ -93,14 +93,14 @@
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<div class="px-5">
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
<div class="rounded-xl bg-(--color-surface-2)" style="height: 300px; position: relative;">
<img
bind:this={imgEl}
alt="Crop preview"
style="display:block; max-width:100%; max-height:100%;"
/>
</div>
<p class="text-xs text-zinc-500 text-center mt-3">
<p class="text-xs text-(--color-muted) text-center mt-3">
Drag to reposition · pinch or scroll to zoom · drag corners to resize
</p>
</div>

View File

@@ -243,26 +243,26 @@
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200">
<h2 class="text-base font-semibold text-(--color-text)">
Comments
{#if !loading && totalCount > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')}
>Top</Button>
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')}
>New</Button>
</div>
@@ -279,12 +279,12 @@
rows={3}
/>
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="default"
@@ -298,8 +298,8 @@
</div>
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Log in</a>
to leave a comment.
</p>
{/if}
@@ -309,17 +309,17 @@
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-red-400">{loadError}</p>
<p class="text-sm text-(--color-danger)">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
<p class="text-sm text-(--color-muted)">No comments yet. Be the first!</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
@@ -328,39 +328,39 @@
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
<div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-(--color-muted)">Anonymous</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title="Upvote"
>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title="Upvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
@@ -368,14 +368,14 @@
</Button>
<!-- Downvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title="Downvote"
>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title="Downvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
@@ -384,11 +384,11 @@
<!-- Reply button -->
{#if isLoggedIn}
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => {
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
@@ -409,14 +409,14 @@
<!-- Delete (owner only) -->
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
@@ -427,26 +427,26 @@
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<Textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-zinc-400 hover:text-zinc-200"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button>
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<Textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button>
<Button
variant="default"
size="sm"
@@ -462,59 +462,59 @@
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
<div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
</div>
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-(--color-muted)">Anonymous</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
</div>
<!-- Reply body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote"
>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote"
>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
@@ -522,14 +522,14 @@
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>

View File

@@ -16,10 +16,10 @@
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
const variants: Record<Variant, string> = {
default: 'border-transparent bg-amber-400 text-zinc-900',
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
outline: 'border-zinc-600 text-zinc-300',
destructive: 'border-transparent bg-red-500/20 text-red-400',
default: 'border-transparent bg-(--color-brand) text-(--color-surface)',
secondary: 'border-transparent bg-(--color-surface-3) text-(--color-text)',
outline: 'border-(--color-border) text-(--color-muted)',
destructive: 'border-transparent bg-(--color-danger)/20 text-(--color-danger)',
};
</script>

View File

@@ -28,15 +28,15 @@
}: Props = $props();
const base =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50';
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-surface) disabled:pointer-events-none disabled:opacity-50';
const variants: Record<Variant, string> = {
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
default: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)',
secondary: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-border)',
outline: 'border border-(--color-border) bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
ghost: 'bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
destructive: 'bg-(--color-danger)/20 text-(--color-danger) hover:bg-(--color-danger)/30',
link: 'text-(--color-brand) underline-offset-4 hover:underline bg-transparent p-0 h-auto',
};
const sizes: Record<Size, string> = {

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
<div class={cn('rounded-xl border border-(--color-border) bg-(--color-surface-2)/50', className)}>
{@render children?.()}
</div>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</script>
<p class={cn('text-sm text-zinc-400', className)}>
<p class={cn('text-sm text-(--color-muted)', className)}>
{@render children?.()}
</p>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</script>
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
<h3 class={cn('font-semibold leading-none tracking-tight text-(--color-text)', className)}>
{@render children?.()}
</h3>

View File

@@ -36,7 +36,7 @@
aria-modal="true"
onclick={handleBackdropClick}
>
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}>
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
{@render children?.()}
</div>
</div>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</script>
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-(--color-text)', className)}>
{@render children?.()}
</h2>

View File

@@ -12,7 +12,7 @@
<div
role="separator"
class={cn(
'shrink-0 bg-zinc-700',
'shrink-0 bg-(--color-border)',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}

View File

@@ -30,8 +30,8 @@
{rows}
{disabled}
class={cn(
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors',
'focus:outline-none focus:border-amber-400',
'flex w-full rounded-lg border border-(--color-border) bg-(--color-surface-2) px-3 py-2 text-sm text-(--color-text) placeholder-zinc-500 resize-none transition-colors',
'focus:outline-none focus:border-(--color-brand)',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}

View File

@@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
}
/**
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
* Returns null if no avatar exists.
* Returns a presigned GET URL for a user's avatar from MinIO.
* Returns null if no avatar object exists in MinIO for this user.
*/
export async function presignAvatarUrl(userId: string): Promise<string | null> {
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
@@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
return data.url ? rewriteHost(data.url) : null;
}
/**
* Resolves the best available avatar URL for a user.
*
* Priority:
* 1. MinIO — if the user has uploaded a custom avatar it will be found here
* (presigned, short-lived GET URL).
* 2. OAuth provider URL — stored in avatar_url when the account was created
* via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...).
* Returned as-is; the browser fetches it directly.
*
* Pass the raw `avatar_url` field from the PocketBase record as `storedValue`
* so this function can distinguish between a MinIO key and a remote URL without
* an extra DB round-trip.
*
* Returns null when neither source yields an avatar.
*/
export async function resolveAvatarUrl(
userId: string,
storedValue: string | null | undefined
): Promise<string | null> {
// 1. Try MinIO first (custom upload takes priority over OAuth picture).
try {
const minioUrl = await presignAvatarUrl(userId);
if (minioUrl) return minioUrl;
} catch {
// MinIO unavailable — fall through to OAuth fallback.
}
// 2. Fall back to OAuth-provided picture URL if it looks like a remote URL.
if (storedValue && storedValue.startsWith('http')) {
return storedValue;
}
return null;
}
/**
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
*

View File

@@ -54,6 +54,7 @@ export interface PBUserSettings {
auto_next: boolean;
voice: string;
speed: number;
theme?: string;
updated?: string;
}
@@ -541,6 +542,19 @@ export async function getUserByUsername(username: string): Promise<User | null>
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
}
/**
* Look up a user by their PocketBase record ID. Returns null if not found.
*/
export async function getUserById(id: string): Promise<User | null> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 404) return null;
if (!res.ok) return null;
return res.json() as Promise<User>;
}
/**
* Look up a user by email. Returns null if not found.
*/
@@ -765,7 +779,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -780,6 +794,7 @@ export async function saveSettings(
speed: settings.speed,
updated: new Date().toISOString()
};
if (settings.theme !== undefined) payload.theme = settings.theme;
if (userId) payload.user_id = userId;
if (existing) {

View File

@@ -41,4 +41,5 @@ export interface UserSettings {
voice: string;
speed: number;
autoNext: boolean;
theme: string;
}

View File

@@ -37,35 +37,35 @@
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
<div
class="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col items-center justify-center px-6 py-16 font-sans"
class="min-h-screen bg-(--color-surface) text-(--color-text) flex flex-col items-center justify-center px-6 py-16 font-sans"
>
<!-- Large status code -->
<p class="text-[8rem] sm:text-[11rem] font-black leading-none text-zinc-800 select-none tabular-nums">
<p class="text-[8rem] sm:text-[11rem] font-black leading-none bg-(--color-surface-2) select-none tabular-nums">
{code}
</p>
<!-- Title + description -->
<div class="mt-4 text-center max-w-md space-y-2">
<h1 class="text-2xl sm:text-3xl font-bold text-zinc-100">{title}</h1>
<p class="text-zinc-400 text-sm sm:text-base leading-relaxed">{description}</p>
<h1 class="text-2xl sm:text-3xl font-bold text-(--color-text)">{title}</h1>
<p class="text-(--color-muted) text-sm sm:text-base leading-relaxed">{description}</p>
</div>
<!-- Actions -->
<div class="mt-10 flex flex-wrap gap-3 justify-center">
<a
href="/"
class="px-5 py-2.5 rounded-xl bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
class="px-5 py-2.5 rounded-xl bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
Go home
</a>
<button
onclick={() => history.back()}
class="px-5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-200 font-semibold text-sm hover:bg-zinc-700 transition-colors"
class="px-5 py-2.5 rounded-xl bg-(--color-surface-2) border border-(--color-border) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors"
>
Go back
</button>
</div>
<!-- Subtle branding -->
<p class="mt-16 text-xs text-zinc-700 tracking-widest uppercase select-none">libnovel</p>
<p class="mt-16 text-xs text-(--color-muted) tracking-widest uppercase select-none">libnovel</p>
</div>

View File

@@ -13,14 +13,15 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber' };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
settings = {
autoNext: row.auto_next ?? false,
voice: row.voice ?? 'af_bella',
speed: row.speed ?? 1.0
speed: row.speed ?? 1.0,
theme: row.theme ?? 'amber'
};
}
} catch (e) {

View File

@@ -2,6 +2,7 @@
import '../app.css';
import { page, navigating } from '$app/state';
import { goto } from '$app/navigation';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -21,24 +22,45 @@
// AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null);
// ── Theme ──────────────────────────────────────────────────────────────
let currentTheme = $state(data.settings?.theme ?? 'amber');
// Expose theme state to child pages (e.g. profile theme picker)
setContext('theme', {
get current() { return currentTheme; },
set current(v: string) { currentTheme = v; }
});
$effect(() => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', currentTheme);
}
});
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
$effect(() => {
if (!settingsApplied && data.settings) {
settingsApplied = true;
audioStore.autoNext = data.settings.autoNext;
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
if (data.settings) {
if (!settingsApplied) {
settingsApplied = true;
audioStore.autoNext = data.settings.autoNext;
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
// Always sync theme (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
}
});
// ── Persist settings changes (debounced 800ms) ──────────────────────────
let settingsSaveTimer = 0;
$effect(() => {
// Subscribe to the three settings fields
// Subscribe to the four settings fields
const autoNext = audioStore.autoNext;
const voice = audioStore.voice;
const speed = audioStore.speed;
const theme = currentTheme;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
@@ -48,7 +70,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed })
body: JSON.stringify({ autoNext, voice, speed, theme })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -170,6 +192,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
<!-- Apply theme before first paint to avoid flash -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<script>document.documentElement.setAttribute('data-theme','${data.settings?.theme ?? 'amber'}')</script>`}
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
{#if env.PUBLIC_UMAMI_WEBSITE_ID && env.PUBLIC_UMAMI_SCRIPT_URL}
<script
@@ -216,18 +241,18 @@
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-zinc-800">
<div class="h-full bg-amber-400 animate-progress-bar"></div>
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
<div class="h-full bg-(--color-brand) animate-progress-bar"></div>
</div>
{/if}
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300 shrink-0">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
libnovel
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-zinc-400 text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
<span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
{page.data.book.title}
</span>
{/if}
@@ -236,13 +261,13 @@
<!-- Desktop nav links (hidden on mobile) -->
<a
href="/books"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Library
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Catalogue
</a>
@@ -250,35 +275,29 @@
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-zinc-400 hover:text-zinc-100"
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
>
Feedback
</a>
<div class="ml-auto flex items-center gap-4">
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
{#if data.user?.role === 'admin'}
<a
href="/admin/scrape"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Scrape
</a>
<a
href="/admin/audio"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Audio
</a>
{/if}
{#if data.user?.role === 'admin'}
<a
href="/admin/scrape"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Admin
</a>
{/if}
<a
href="/profile"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{data.user.username}
</a>
<form method="POST" action="/logout" class="hidden sm:block">
<Button type="submit" variant="ghost" size="sm" class="text-zinc-400 hover:text-zinc-100">
<Button type="submit" variant="ghost" size="sm" class="text-(--color-muted) hover:text-(--color-text)">
Sign out
</Button>
</form>
@@ -309,7 +328,7 @@
<div class="ml-auto">
<a
href="/login"
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors"
class="text-sm px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Sign in
</a>
@@ -319,18 +338,18 @@
<!-- Mobile drawer (full-width, below the bar) -->
{#if data.user && menuOpen}
<div class="sm:hidden border-t border-zinc-700 bg-zinc-900 px-4 py-3 flex flex-col gap-1">
<div class="sm:hidden border-t border-(--color-border) bg-(--color-surface) px-4 py-3 flex flex-col gap-1">
<a
href="/books"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Library
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Catalogue
</a>
@@ -339,48 +358,34 @@
target="_blank"
rel="noopener noreferrer"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
>
Feedback ↗
</a>
<a
href="/profile"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Profile <span class="text-zinc-500 font-normal">({data.user.username})</span>
Profile <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
</a>
{#if data.user?.role === 'admin'}
<div class="my-1 border-t border-zinc-700/60"></div>
<p class="px-3 pt-1 pb-0.5 text-xs text-zinc-600 uppercase tracking-widest">Admin</p>
<a
href="/admin/scrape"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Scrape tasks
</a>
<a
href="/admin/audio"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Audio cache
</a>
<a
href="/admin/audio-jobs"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Audio jobs
</a>
{/if}
<div class="my-1 border-t border-zinc-700/60"></div>
{#if data.user?.role === 'admin'}
<div class="my-1 border-t border-(--color-border)/60"></div>
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">Admin</p>
<a
href="/admin/scrape"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Admin panel
</a>
{/if}
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<Button
type="submit"
variant="ghost"
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-red-400 hover:bg-zinc-800 hover:text-red-300"
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-(--color-danger) hover:bg-(--color-surface-2) hover:text-(--color-danger)"
>
Sign out
</Button>
@@ -395,17 +400,17 @@
{/key}
</main>
<footer class="border-t border-zinc-800 mt-auto">
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600">
<footer class="border-t border-(--color-border) mt-auto">
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Catalogue</a>
<a href="/books" class="hover:text-(--color-text) transition-colors">Library</a>
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">Catalogue</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
>
Feedback
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -417,7 +422,7 @@
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
>
novelfire.net
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -427,23 +432,32 @@
</a>
</nav>
<!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-zinc-700">
<a href="/disclaimer" class="hover:text-zinc-500 transition-colors">Disclaimer</a>
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">Disclaimer</a>
<a href="/privacy" class="hover:text-(--color-text) transition-colors">Privacy</a>
<a href="/dmca" class="hover:text-(--color-text) transition-colors">DMCA</a>
<span>&copy; {new Date().getFullYear()} libnovel</span>
</div>
<!-- Build version / commit SHA -->
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700">
<!-- Build version / commit SHA / build time -->
{#snippet buildTime()}
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
<span class="text-(--color-muted)" title="Build time">
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
</span>
{/if}
{/snippet}
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-(--color-surface-2) border border-(--color-border)">
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
<span class="text-zinc-300" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
<span class="text-(--color-text)" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
{#if env.PUBLIC_BUILD_COMMIT && env.PUBLIC_BUILD_COMMIT !== 'unknown'}
<span class="text-zinc-500 select-all" title="Commit SHA"
<span class="text-(--color-muted) select-all" title="Commit SHA"
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
>
{/if}
{@render buildTime()}
{:else}
<span class="text-zinc-400">dev</span>
<span class="text-(--color-muted)">dev</span>
{/if}
</div>
</div>
@@ -452,20 +466,20 @@
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-zinc-700 bg-zinc-900 max-h-[32rem] overflow-y-auto">
<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-zinc-800 sticky top-0 bg-zinc-900">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</span>
<Button
variant="ghost"
size="icon"
onclick={() => (chapterDrawerOpen = false)}
aria-label="Close chapter list"
class="h-6 w-6 text-zinc-600 hover:text-zinc-300"
class="h-6 w-6 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 9l-7 7-7-7"/>
@@ -476,16 +490,16 @@
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter
? 'text-amber-400 font-semibold'
: 'text-zinc-400'}"
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-zinc-600 w-8 shrink-0 text-right">
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<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}
@@ -497,9 +511,9 @@
<!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-zinc-800">
<div class="h-0.5 bg-(--color-surface-2)">
<div
class="h-full bg-amber-400 transition-none"
class="h-full bg-(--color-brand) transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
@@ -512,8 +526,8 @@
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1 accent-amber-400 cursor-pointer block"
style="margin: 0; border-radius: 0;"
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
/>
</div>
{/if}
@@ -522,27 +536,27 @@
<!-- Track info (click to open chapter list drawer) -->
<button
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors"
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
>
{#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
{#if audioStore.bookTitle}
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p>
<p class="text-xs text-(--color-muted) truncate leading-tight">{audioStore.bookTitle}</p>
{/if}
{#if audioStore.status === 'generating'}
<p class="text-xs text-amber-400 leading-tight">
<p class="text-xs text-(--color-brand) leading-tight">
Generating… {Math.round(audioStore.progress)}%
</p>
{:else if audioStore.status === 'ready'}
<p class="text-xs text-zinc-500 tabular-nums leading-tight">
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</p>
{:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
<p class="text-xs text-(--color-muted) leading-tight">Loading…</p>
{/if}
</button>
@@ -561,10 +575,10 @@
</svg>
</Button>
<!-- Play / Pause — custom circular amber style, kept as raw button -->
<!-- Play / Pause — custom circular brand style, kept as raw button -->
<button
onclick={togglePlay}
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0"
class="w-10 h-10 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
@@ -595,7 +609,7 @@
<!-- Speed control — fixed-width pill, kept as raw button -->
<button
onclick={cycleSpeed}
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center"
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
title="Change playback speed"
aria-label="Playback speed {audioStore.speed}x"
>
@@ -608,8 +622,8 @@
class={cn(
'relative p-1.5 rounded flex-shrink-0 transition-colors',
audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'
? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
title={audioStore.autoNext
? audioStore.nextStatus === 'prefetched'
@@ -627,14 +641,14 @@
</svg>
<!-- Prefetch status dot -->
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
{:else if audioStore.status === 'generating'}
<!-- Spinner during generation -->
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<svg class="w-6 h-6 text-(--color-brand) 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>
@@ -656,8 +670,8 @@
/>
{:else}
<!-- Fallback book icon -->
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700">
<svg class="w-4 h-4 text-zinc-500" fill="currentColor" viewBox="0 0 24 24">
<div class="w-8 h-11 flex items-center justify-center bg-(--color-surface-2) rounded border border-(--color-border)">
<svg class="w-4 h-4 text-(--color-muted)" 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>
@@ -672,7 +686,7 @@
onclick={dismiss}
title="Close player"
aria-label="Close player"
class="text-zinc-600 hover:text-zinc-400 flex-shrink-0"
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="M6 18L18 6M6 6l12 12"/>

View File

@@ -21,17 +21,17 @@
<!-- Stats bar -->
<div class="flex gap-6 mb-8 text-center">
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p>
<p class="text-xs text-zinc-400 mt-0.5">Books</p>
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Books</p>
</div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p>
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p>
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Chapters</p>
</div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p>
<p class="text-xs text-zinc-400 mt-0.5">In progress</p>
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
<p class="text-xs text-(--color-muted) mt-0.5">In progress</p>
</div>
</div>
@@ -39,16 +39,16 @@
{#if data.continueReading.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
<h2 class="text-lg font-bold text-(--color-text)">Continue Reading</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.continueReading as { book, chapter }}
<a
href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
@@ -57,7 +57,7 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -65,14 +65,14 @@
</div>
{/if}
<!-- Chapter badge overlay -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
@@ -85,17 +85,17 @@
{#if data.recentlyUpdated.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
<h2 class="text-lg font-bold text-(--color-text)">Recently Updated</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.recentlyUpdated as book}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
@@ -104,7 +104,7 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -113,17 +113,17 @@
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) self-start">{book.status}</span>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
@@ -136,12 +136,12 @@
<!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<div class="text-center py-20 text-zinc-500">
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p>
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">Your library is empty</p>
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
<a
href="/catalogue"
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
>
Discover Novels
</a>
@@ -152,16 +152,16 @@
{#if data.subscriptionFeed.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
<h2 class="text-lg font-bold text-(--color-text)">From People You Follow</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.subscriptionFeed as { book, readerUsername }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
@@ -170,7 +170,7 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -179,18 +179,18 @@
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<p class="text-xs text-zinc-600 truncate mt-0.5">
<p class="text-xs text-(--color-muted) truncate mt-0.5">
via <span class="text-amber-500/70">{readerUsername}</span>
</p>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 1) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
const adminTabs = [
const internalLinks = [
{ href: '/admin/scrape', label: 'Scrape' },
{ href: '/admin/audio', label: 'Audio' }
{ href: '/admin/audio', label: 'Audio' },
{ href: '/admin/changelog', label: 'Changelog' }
];
const toolTabs = [
const externalLinks = [
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' },
{ href: 'https://errors.libnovel.cc', label: 'Errors' },
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' },
@@ -21,36 +22,51 @@
let { children }: Props = $props();
</script>
<!-- Admin nav: internal pages + external tools -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<!-- Internal admin pages -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
{#each adminTabs as tab}
<a
href={tab.href}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{page.url.pathname.startsWith(tab.href)
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
>
{tab.label}
</a>
{/each}
</div>
<div class="flex min-h-[calc(100vh-4rem)] gap-0">
<!-- Sidebar -->
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
<!-- Internal pages -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Pages</p>
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
<a
href={link.href}
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
{page.url.pathname.startsWith(link.href)
? 'bg-(--color-surface-2) text-(--color-text)'
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
>
{link.label}
</a>
{/each}
</nav>
</div>
<!-- External tools (open in new tab) -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
{#each toolTabs as tool}
<a
href={tool.href}
target="_blank"
rel="noopener noreferrer"
class="px-4 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors"
>
{tool.label}
</a>
{/each}
</div>
<!-- External tools -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Tools</p>
<nav class="flex flex-col gap-0.5">
{#each externalLinks as link}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
>
{link.label}
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/each}
</nav>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0 px-8 py-6">
{@render children?.()}
</main>
</div>
{@render children?.()}

View File

@@ -33,10 +33,10 @@
// ── Helpers ──────────────────────────────────────────────────────────────────
function jobStatusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse';
if (status === 'generating') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
return 'text-zinc-300';
if (status === 'failed') return 'text-(--color-danger)';
return 'text-(--color-text)';
}
function fmtDate(s: string) {
@@ -100,30 +100,30 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1>
<p class="text-zinc-400 text-sm mt-1">
<h1 class="text-2xl font-bold text-(--color-text)">Audio</h1>
<p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0}
&middot; <span class="text-red-400">{stats.failed} failed</span>
&middot; <span class="text-(--color-danger)">{stats.failed} failed</span>
{/if}
{#if stats.inFlight > 0}
&middot; <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
&middot; <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
{/if}
&middot; {entries.length} cached file{entries.length !== 1 ? 's' : ''}
</p>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700">
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
<button
onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Jobs
{#if stats.inFlight > 0}
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold">
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold">
{stats.inFlight}
</span>
{/if}
@@ -131,7 +131,7 @@
<button
onclick={() => (activeTab = 'cache')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
{activeTab === 'cache' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Cache
</button>
@@ -143,18 +143,18 @@
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, voice or status…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if filteredJobs.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
@@ -164,23 +164,23 @@
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a>
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
</td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-red-950/20">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono">{job.error_message}</td>
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
@@ -191,21 +191,21 @@
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors truncate">
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span>
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
{#if job.error_message}
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p>
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
</div>
{/each}
@@ -219,18 +219,18 @@
type="search"
bind:value={cacheQ}
placeholder="Filter by slug, chapter or voice…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if filteredCache.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th>
@@ -239,19 +239,19 @@
<th class="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{parts.slug}" class="hover:text-amber-400 transition-colors">{parts.slug}</a>
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{parts.slug}" class="hover:text-(--color-brand) transition-colors">{parts.slug}</a>
</td>
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
<td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(entry.updated)}</td>
</tr>
{/each}
</tbody>
@@ -262,17 +262,17 @@
<div class="sm:hidden space-y-3">
{#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
{parts.slug}
</a>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span>
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span>
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
{#if entry.filename}
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p>
<p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if}
</div>
{/each}

View File

@@ -0,0 +1,26 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import type { PageServerLoad } from './$types';
export interface Release {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}
export const load: PageServerLoad = async () => {
try {
// releases.json is baked into the image at build time by CI.
// SvelteKit Node adapter copies static/ → build/client/, so the file
// lives at <cwd>/build/client/releases.json in production.
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
const releases: Release[] = JSON.parse(raw);
return { releases: releases.filter((r) => !r.draft) };
} catch (e) {
return { releases: [], error: String(e) };
}
};

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function fmtDate(s: string) {
return new Date(s).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric'
});
}
</script>
<svelte:head>
<title>Changelog — libnovel admin</title>
</svelte:head>
<div class="space-y-6 max-w-2xl">
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Changelog</h1>
<a
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
>
Gitea releases
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
{#if data.error}
<p class="text-sm text-(--color-danger)">Could not load releases: {data.error}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">No releases found.</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -193,10 +193,10 @@
// ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-amber-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
if (status === 'cancelled') return 'text-zinc-400';
return 'text-zinc-300';
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
function fmtDate(s: string) {
@@ -231,148 +231,127 @@
<title>Scrape tasks — libnovel admin</title>
</svelte:head>
<div class="space-y-8">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
<p class="text-zinc-400 text-sm mt-1">
Job status:
{#if running}
<span class="text-amber-400 font-medium animate-pulse">Running</span>
{:else}
<span class="text-green-400 font-medium">Idle</span>
{/if}
</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Scrape</h1>
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
{running ? 'Running' : 'Idle'}
</span>
</div>
<!-- Scrape controls -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Compact controls -->
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Full catalogue -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<div>
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
</div>
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Full catalogue</span>
<button
onclick={triggerCatalogueScrape}
disabled={running || cataloguing}
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
</button>
{#if catalogueError}
<p class="text-sm text-red-400">{catalogueError}</p>
{/if}
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
</div>
<!-- Single book -->
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
<div class="flex gap-2">
<input
type="url"
bind:value={scrapeUrl}
placeholder="https://novelfire.net/book/…"
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
onclick={() => triggerBookScrape(scrapeUrl)}
disabled={!scrapeUrl.trim() || running || scraping}
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
>
{scraping ? 'Queuing…' : 'Scrape'}
</button>
</div>
{#if scrapeError}
<p class="text-sm text-red-400">{scrapeError}</p>
{/if}
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Single book</span>
<input
type="url"
bind:value={scrapeUrl}
placeholder="https://novelfire.net/book/…"
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<button
onclick={() => triggerBookScrape(scrapeUrl)}
disabled={!scrapeUrl.trim() || running || scraping}
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
{scraping ? 'Queuing…' : 'Scrape'}
</button>
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
</div>
<!-- Range scrape -->
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2>
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Chapter range</span>
<input
type="url"
bind:value={rangeUrl}
placeholder="https://novelfire.net/book/…"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<div class="flex gap-2">
<input
type="number"
bind:value={rangeFrom}
min="1"
placeholder="From ch."
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="number"
bind:value={rangeTo}
min="1"
placeholder="To ch. (opt)"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
onclick={triggerRangeScrape}
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
>
{ranging ? 'Queuing…' : 'Go'}
</button>
</div>
{#if rangeError}
<p class="text-sm text-red-400">{rangeError}</p>
{/if}
</div>
</div>
<!-- Quick-scrape genre links -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
<div class="flex flex-wrap gap-2">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
>
{qs.label}
</button>
{/each}
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
<input
type="number"
bind:value={rangeFrom}
min="1"
placeholder="From"
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<input
type="number"
bind:value={rangeTo}
min="1"
placeholder="To"
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<button
onclick={triggerRangeScrape}
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
Browse novelfire.net ↗
</a>
{ranging ? 'Queuing…' : 'Go'}
</button>
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
</div>
<!-- Quick genre chips -->
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Quick genres</span>
<div class="flex flex-wrap gap-1.5">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand-dim) transition-colors"
>
{qs.label}
</button>
{/each}
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-2.5 py-1 rounded text-xs font-medium text-(--color-muted) border border-(--color-border)/50 hover:text-(--color-brand-dim) hover:border-(--color-brand)/40 transition-colors"
>
novelfire.net ↗
</a>
</div>
</div>
</div>
<!-- Tasks table -->
<div class="space-y-3">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2>
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">Task history</h2>
<input
type="search"
bind:value={q}
placeholder="Filter by kind, status or URL…"
class="w-full max-w-xs bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full max-w-xs 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>
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
<p class="text-(--color-muted) text-sm py-8 text-center">
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Kind / URL</th>
<th class="px-4 py-3 text-left">Status</th>
@@ -385,14 +364,14 @@
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
<tbody class="divide-y divide-(--color-border)/50">
{#each filtered as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-(--color-text)">
{task.kind}
{#if task.target_url}
<br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
<span class="text-(--color-muted) truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</span>
{/if}
@@ -400,19 +379,19 @@
<td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3 text-right text-(--color-text)">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-(--color-text)">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1.5">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
</button>
@@ -434,14 +413,14 @@
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1 w-full">{cancelErrors[task.id]}</p>
<p class="text-xs text-(--color-danger) mt-1 w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</td>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
<tr class="bg-(--color-danger)/10">
<td colspan="9" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{task.error_message}</td>
</tr>
{/if}
{/each}
@@ -452,12 +431,12 @@
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filtered as task}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="font-mono text-xs text-zinc-300">{task.kind}</span>
<span class="font-mono text-xs text-(--color-text)">{task.kind}</span>
{#if task.target_url}
<p class="text-xs text-zinc-500 truncate mt-0.5" title={task.target_url}>
<p class="text-xs text-(--color-muted) truncate mt-0.5" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</p>
{/if}
@@ -465,22 +444,22 @@
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span>
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span>
<span class="text-(--color-muted)">Books</span><span class="text-(--color-text) text-right">{task.books_found ?? 0}</span>
<span class="text-(--color-muted)">Chapters</span><span class="text-(--color-text) text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-(--color-muted)">Skipped</span><span class="text-(--color-muted) text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-(--color-muted)">Errors</span><span class="{task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'} text-right">{task.errors ?? 0}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(task.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(task.started, task.finished)}</span>
</div>
{#if task.error_message}
<p class="text-xs text-red-400 font-mono break-all">{task.error_message}</p>
<p class="text-xs text-(--color-danger) font-mono break-all">{task.error_message}</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
</button>
@@ -502,7 +481,7 @@
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 w-full">{cancelErrors[task.id]}</p>
<p class="text-xs text-(--color-danger) w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
/**
* GET /api/auth/me
@@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
}
// Fetch full record from PocketBase to get avatar_url
const record = await getUserByUsername(locals.user.username).catch(() => null);
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
return json({
id: locals.user.id,
username: locals.user.username,
role: locals.user.role,
avatar_url: record?.avatar_url ?? null
avatar_url: avatarUrl
});
};

View File

@@ -5,9 +5,10 @@ import {
listReplies,
createComment,
getMyVotes,
getUserById,
type CommentSort
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
@@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
replies: repliesPerComment[i]
}));
// Batch-resolve avatar presign URLs for all unique user_ids
// Batch-resolve avatar URLs for all unique user_ids
// MinIO first (custom upload), fall back to OAuth provider picture.
const allComments = [...topLevel, ...allReplies];
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
const avatarEntries = await Promise.all(
uniqueUserIds.map(async (userId) => {
try {
const url = await presignAvatarUrl(userId);
const user = await getUserById(userId);
const url = await resolveAvatarUrl(userId, user?.avatar_url);
return [userId, url] as [string, string | null];
} catch {
return [userId, null] as [string, null];

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignAvatarUrl } from '$lib/server/minio';
import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
import { backendFetch } from '$lib/server/scraper';
@@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const record = await getUserByUsername(locals.user.username).catch(() => null);
if (!record?.avatar_url) {
return json({ avatar_url: null });
}
const avatarUrl = await presignAvatarUrl(locals.user.id);
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
return json({ avatar_url: avatarUrl });
};

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed).
* Returns the current user's settings (auto_next, voice, speed, theme).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -14,7 +14,8 @@ export const GET: RequestHandler = async ({ locals }) => {
return json({
autoNext: settings?.auto_next ?? false,
voice: settings?.voice ?? 'af_bella',
speed: settings?.speed ?? 1.0
speed: settings?.speed ?? 1.0,
theme: settings?.theme ?? 'amber'
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -24,7 +25,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number }
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -39,6 +40,12 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
// theme is optional — if provided it must be a known value
const validThemes = ['amber', 'slate', 'rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {

View File

@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
@@ -15,11 +15,9 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const profile = await getPublicProfile(username);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar presigned URL if set
// Resolve avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
// Is the current logged-in user subscribed?
let isSubscribed = false;

View File

@@ -20,18 +20,18 @@
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
<p class="text-zinc-400 text-sm mt-1">
<h1 class="text-2xl font-bold text-(--color-text)">Library</h1>
<p class="text-(--color-muted) text-sm mt-1">
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
</p>
</div>
{#if !data.books?.length}
<div class="text-center py-20 text-zinc-500">
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg">Your library is empty.</p>
<p class="text-sm mt-2">
Books you start reading or save from
<a href="/catalogue" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Discover</a>
will appear here.
</p>
</div>
@@ -42,10 +42,10 @@
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<!-- Cover image -->
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
@@ -54,7 +54,7 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -65,21 +65,21 @@
<!-- Info -->
<div class="p-2 flex flex-col gap-1 flex-1">
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
<h2 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">
{book.title ?? ''}
</h2>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author ?? ''}</p>
<p class="text-xs text-(--color-muted) truncate">{book.author ?? ''}</p>
{/if}
<div class="mt-auto pt-1 flex items-center justify-between gap-1">
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 truncate max-w-[60%]">
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) truncate max-w-[60%]">
{book.status}
</span>
{/if}
{#if lastChapter}
<span class="text-xs text-amber-400 font-medium ml-auto whitespace-nowrap">
<span class="text-xs text-(--color-brand) font-medium ml-auto whitespace-nowrap">
ch.{lastChapter}
</span>
{/if}
@@ -88,7 +88,7 @@
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}

View File

@@ -136,20 +136,20 @@
{#if data.scraping}
<!-- ═══════════════════════════════════════════ Scraping in progress ══ -->
<div class="flex flex-col items-center justify-center py-24 gap-5 text-center">
<svg class="w-10 h-10 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
<svg class="w-10 h-10 text-(--color-brand) 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>
<div>
<p class="text-zinc-200 font-semibold text-lg">Scraping in progress…</p>
<p class="text-zinc-500 text-sm mt-1">
<p class="text-(--color-text) font-semibold text-lg">Scraping in progress…</p>
<p class="text-(--color-muted) text-sm mt-1">
Fetching the first 20 chapters. This page will refresh automatically.
</p>
{#if data.taskId}
<p class="text-zinc-600 text-xs mt-2 font-mono">task: {data.taskId}</p>
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
{/if}
</div>
<a href="/" class="mt-2 text-sm text-amber-400 hover:text-amber-300 transition-colors">← Home</a>
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">← Home</a>
</div>
{:else}
@@ -165,7 +165,7 @@
aria-hidden="true"
></div>
{/if}
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900/60 to-zinc-900/95 pointer-events-none" aria-hidden="true"></div>
<div class="absolute inset-0 bg-gradient-to-b from-(--color-surface)/60 to-(--color-surface)/95 pointer-events-none" aria-hidden="true"></div>
<div class="relative flex flex-col p-5 sm:p-7 gap-4">
<!-- Cover + meta row -->
@@ -175,7 +175,7 @@
<img
src={book.cover}
alt={book.title}
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-(--color-border) shadow-xl self-start"
/>
{/if}
@@ -183,10 +183,10 @@
<div class="flex flex-col gap-2 min-w-0 flex-1">
<!-- Title + "not in library" badge -->
<div class="flex items-start gap-2 flex-wrap">
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{book.title}</h1>
<h1 class="text-xl sm:text-3xl font-bold text-(--color-text) leading-tight">{book.title}</h1>
{#if !data.inLib}
<span
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0"
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) shrink-0"
title="This book was fetched live from the source and is not yet in your library"
>
not in library
@@ -196,29 +196,29 @@
<!-- Author -->
{#if book.author}
<p class="text-zinc-400 text-sm">{book.author}</p>
<p class="text-(--color-muted) text-sm">{book.author}</p>
{/if}
<!-- Status + genres -->
<div class="flex flex-wrap gap-1.5 mt-0.5">
{#if book.status}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{book.status}</span>
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700">{genre}</span>
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
{/each}
</div>
<!-- Summary with expand toggle -->
{#if book.summary}
<div class="mt-1">
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
<p class="text-(--color-muted) text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
{book.summary}
</p>
{#if book.summary.length > 220}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
>
{summaryExpanded ? 'Less' : 'More'}
</button>
@@ -231,7 +231,7 @@
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="px-5 py-2 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
Continue ch.{data.lastChapter}
</a>
@@ -241,8 +241,8 @@
href="/books/{book.slug}/chapters/1"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
@@ -254,8 +254,8 @@
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -282,7 +282,7 @@
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
Continue ch.{data.lastChapter}
</a>
@@ -292,8 +292,8 @@
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
@@ -305,8 +305,8 @@
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -329,19 +329,19 @@
</div>
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
<!-- Chapters row: links to the full chapter list page -->
<a
href="/books/{book.slug}/chapters"
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
class="flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-surface-2)/60 transition-colors group"
>
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-(--color-brand) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
<span class="text-sm font-semibold text-(--color-text)">Chapters</span>
{#if chapterList.length > 0}
<span class="text-xs text-zinc-500">
<span class="text-xs text-(--color-muted)">
{#if data.lastChapter && data.lastChapter > 0}
Reading ch.{data.lastChapter} of {chapterList.length}
{:else}
@@ -350,7 +350,7 @@
</span>
{/if}
</div>
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-muted) transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
@@ -360,7 +360,7 @@
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)/50 transition-colors text-left"
>
<svg class="w-3.5 h-3.5 flex-shrink-0" 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"/>
@@ -373,14 +373,14 @@
</button>
{#if adminOpen}
<div class="px-4 py-3 border-t border-zinc-800 flex flex-col gap-4">
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-4">
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-zinc-700 text-zinc-500 cursor-not-allowed' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -396,7 +396,7 @@
{/if}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
</span>
{/if}
@@ -405,25 +405,25 @@
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
<label for="range-from" class="text-xs text-(--color-muted)">From chapter</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
<label for="range-to" class="text-xs text-(--color-muted)">To chapter (optional)</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
@@ -431,13 +431,13 @@
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{rangeScraping ? 'Queuing…' : 'Scrape range'}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
</span>
{/if}

View File

@@ -64,32 +64,32 @@
<div class="flex items-center gap-3 mb-5">
<a
href="/books/{data.book.slug}"
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
class="flex items-center gap-1.5 text-(--color-muted) hover:text-(--color-text) transition-colors text-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
Back
</a>
<span class="text-zinc-700">/</span>
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
<span class="text-(--color-border)">/</span>
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
</div>
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
<div class="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder-zinc-500 text-sm focus:outline-none focus:border-(--color-brand) transition-colors"
/>
{#if searchQuery}
<button
onclick={() => (searchQuery = '')}
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
class="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-muted) hover:text-(--color-text)"
aria-label="Clear search"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -107,9 +107,9 @@
onclick={() => (activeGroup = i)}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:bg-(--color-surface-3) hover:text-(--color-text)'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-(--color-brand)/50' : ''}"
>
{groupLabel(i)}
</button>
@@ -121,7 +121,7 @@
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
<button
onclick={() => (activeGroup = currentGroup)}
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/25 text-(--color-brand) text-sm hover:bg-(--color-brand)/20 transition-colors"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
@@ -133,14 +133,14 @@
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
{#if visibleChapters.length === 0}
{#if searchQuery}
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
<p class="text-(--color-muted) text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
{:else}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
<p class="text-(--color-muted) text-sm">No chapters available yet.</p>
{/if}
{:else}
<!-- Result count while searching -->
{#if searchQuery}
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
<p class="text-xs text-(--color-muted) mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
{/if}
<div class="flex flex-col gap-0.5">
@@ -150,12 +150,12 @@
href="/books/{data.book.slug}/chapters/{chapter.number}"
id="ch-{chapter.number}"
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
>
<!-- Number badge -->
<span
class="w-9 text-right text-sm font-mono flex-shrink-0
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
>
{chapter.number}
</span>
@@ -163,21 +163,21 @@
<!-- Title -->
<span
class="flex-1 min-w-0 text-sm truncate transition-colors
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
>
{chapter.title || `Chapter ${chapter.number}`}
</span>
<!-- Date — desktop only -->
{#if chapter.date_label}
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
{chapter.date_label}
</span>
{/if}
<!-- Reading indicator -->
{#if isCurrent}
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">reading</span>
{/if}
</a>
{/each}
@@ -185,15 +185,15 @@
<!-- Bottom page-group nav (mirrors top, for long lists) -->
{#if !searchQuery && totalGroups > 1}
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-(--color-border)">
{#each Array(totalGroups) as _, i}
<button
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:bg-(--color-surface-3) hover:text-(--color-text)'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-(--color-brand)/50' : ''}"
>
{groupLabel(i)}
</button>

View File

@@ -69,7 +69,7 @@
<div class="flex items-center justify-between mb-6 gap-4">
<a
href="/books/{data.book.slug}"
class="text-zinc-400 hover:text-zinc-100 text-sm flex items-center gap-1 transition-colors"
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -81,7 +81,7 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
>
&larr; Ch.{data.prev}
</a>
@@ -89,7 +89,7 @@
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Ch.{data.next} &rarr;
</a>
@@ -99,11 +99,11 @@
<!-- Chapter heading -->
<div class="mb-6">
<h1 class="text-xl font-bold text-zinc-100">
<h1 class="text-xl font-bold text-(--color-text)">
{data.chapter.title || `Chapter ${data.chapter.number}`}
</h1>
{#if wordCount > 0}
<p class="text-zinc-600 text-xs mt-1">{wordCount.toLocaleString()} words</p>
<p class="text-(--color-muted) text-xs mt-1">{wordCount.toLocaleString()} words</p>
{/if}
</div>
@@ -120,14 +120,14 @@
voices={data.voices}
/>
{:else}
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
Preview chapter — audio not available for books outside the library.
</div>
{/if}
<!-- Chapter content -->
{#if fetchingContent}
<div class="flex flex-col items-center gap-3 py-16 text-zinc-500 text-sm">
<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">
<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>
@@ -135,7 +135,7 @@
Fetching chapter…
</div>
{:else if !html}
<div class="text-zinc-500 text-center py-16">
<div class="text-(--color-muted) text-center py-16">
<p>{fetchError || 'Chapter content not available.'}</p>
</div>
{:else}
@@ -145,11 +145,11 @@
{/if}
<!-- Bottom nav -->
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800 gap-4">
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
>
&larr; Previous chapter
</a>
@@ -159,7 +159,7 @@
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Next chapter &rarr;
</a>

View File

@@ -22,11 +22,11 @@
filterStatus = data.status;
});
function navigateWithFilters(overrides: { sort?: string; genre?: string; status?: string }) {
function applyFilters() {
const params = new URLSearchParams();
params.set('sort', overrides.sort ?? filterSort);
params.set('genre', overrides.genre ?? filterGenre);
params.set('status', overrides.status ?? filterStatus);
params.set('sort', filterSort);
params.set('genre', filterGenre);
params.set('status', filterStatus);
params.set('page', '1');
goto(`/catalogue?${params.toString()}`);
}
@@ -234,12 +234,12 @@
<!-- Header -->
<div class="mb-4">
<h1 class="text-2xl font-bold text-zinc-100">Catalogue</h1>
<p class="text-zinc-400 text-sm mt-1">
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue</h1>
<p class="text-(--color-muted) text-sm mt-1">
{#if isSearchView}
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-(--color-text)">{data.searchQuery}</span>"
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
<span class="text-(--color-muted) text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
{/if}
{:else if isRankView}
{#if novels.length > 0}
@@ -264,7 +264,7 @@
A scrape job is already running. Check back once it finishes.
</div>
{:else if form.status === 'error'}
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
<div class="mb-4 px-4 py-3 rounded bg-(--color-danger)/10 border border-(--color-danger) text-(--color-danger) text-sm">
Failed to queue scrape. Check that the scraper service is reachable.
</div>
{/if}
@@ -279,18 +279,18 @@
name="q"
value={data.searchQuery}
placeholder="Search…"
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) placeholder-zinc-500"
/>
<button
type="submit"
class="px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
class="px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors whitespace-nowrap"
>
Search
</button>
{#if data.searchQuery}
<a
href="/catalogue"
class="px-3 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
class="px-3 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors whitespace-nowrap"
>
Clear
</a>
@@ -304,8 +304,8 @@
aria-expanded={filtersOpen}
class="relative flex items-center gap-1.5 px-3 py-2 rounded border text-sm font-medium transition-colors whitespace-nowrap
{filtersOpen
? 'bg-zinc-700 border-zinc-500 text-zinc-100'
: 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100'}"
? 'bg-(--color-surface-3) border-zinc-500 text-(--color-text)'
: 'bg-(--color-surface-2) border-(--color-border) text-(--color-text) hover:border-zinc-500 hover:text-(--color-text)'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -314,18 +314,18 @@
<span class="hidden sm:inline">Filters</span>
<!-- Active indicator dot -->
{#if hasActiveFilters}
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-amber-400"></span>
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-(--color-brand)"></span>
{/if}
</button>
<!-- View toggle -->
<div class="flex items-center bg-zinc-800 border border-zinc-700 rounded overflow-hidden shrink-0">
<div class="flex items-center bg-(--color-surface-2) border border-(--color-border) rounded overflow-hidden shrink-0">
<button
onclick={() => (view = 'grid')}
title="Grid view"
class="px-2.5 py-2 transition-colors {view === 'grid'
? 'bg-zinc-600 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
? 'bg-(--color-surface-3) text-(--color-text)'
: '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"
@@ -336,8 +336,8 @@
onclick={() => (view = 'list')}
title="List view"
class="px-2.5 py-2 transition-colors {view === 'list'
? 'bg-zinc-600 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
? 'bg-(--color-surface-3) text-(--color-text)'
: '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"
@@ -362,7 +362,7 @@
<button
type="submit"
disabled={refreshing}
class="hidden sm:block px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
class="hidden sm:block px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{refreshing ? 'Queuing…' : 'Refresh'}
</button>
@@ -372,9 +372,9 @@
<!-- Active filter summary (shown when panel is closed and filters are active) -->
{#if !filtersOpen && hasActiveFilters}
<p class="text-xs text-zinc-500 mb-3">
<span class="text-zinc-400">{filterSummary}</span>
<a href="/catalogue" class="ml-2 text-zinc-600 hover:text-zinc-400 underline underline-offset-2">clear</a>
<p class="text-xs text-(--color-muted) mb-3">
<span class="text-(--color-muted)">{filterSummary}</span>
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">clear</a>
</p>
{/if}
@@ -397,25 +397,24 @@
<button
type="submit"
disabled={refreshing}
class="w-full px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
</button>
</form>
{/if}
<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700 flex flex-col gap-3">
<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-(--color-surface-2)/60 border border-(--color-border) flex flex-col gap-3">
<input type="hidden" name="page" value="1" />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label for="filter-sort" class="text-xs text-zinc-500 uppercase tracking-wide">Sort</label>
<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">Sort</label>
<select
id="filter-sort"
name="sort"
bind:value={filterSort}
onchange={() => navigateWithFilters({ sort: filterSort })}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) w-full"
>
{#each sorts as s}
<option value={s.value} selected={s.value === filterSort}>{s.label}</option>
@@ -424,14 +423,13 @@
</div>
<div class="flex flex-col gap-1">
<label for="filter-genre" class="text-xs text-zinc-500 uppercase tracking-wide">Genre</label>
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">Genre</label>
<select
id="filter-genre"
name="genre"
bind:value={filterGenre}
onchange={() => navigateWithFilters({ genre: filterGenre })}
disabled={isRankView}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
>
{#each genres as g}
<option value={g.value} selected={g.value === filterGenre}>{g.label}</option>
@@ -440,14 +438,13 @@
</div>
<div class="flex flex-col gap-1">
<label for="filter-status" class="text-xs text-zinc-500 uppercase tracking-wide">Status</label>
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">Status</label>
<select
id="filter-status"
name="status"
bind:value={filterStatus}
onchange={() => navigateWithFilters({ status: filterStatus })}
disabled={isRankView}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
>
{#each statuses as st}
<option value={st.value} selected={st.value === filterStatus}>{st.label}</option>
@@ -457,27 +454,34 @@
</div>
{#if isRankView}
<p class="text-xs text-zinc-500 italic">Genre &amp; status filters apply to Browse only</p>
<p class="text-xs text-(--color-muted) italic">Genre &amp; status filters apply to Browse only</p>
{/if}
<div class="flex gap-2 justify-end">
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
<a href="/catalogue" class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors">
Reset
</a>
<button
type="button"
onclick={applyFilters}
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Apply
</button>
</div>
</form>
{/if}
<!-- Content -->
{#if novels.length === 0}
<div class="text-center py-20 text-zinc-500">
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
<p class="text-sm mt-2">
{#if isSearchView}
Try a different search term.
{:else if isRankView}
{#if data.isAdmin}
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
Click <span class="text-(--color-brand)">Refresh catalogue</span> above to trigger a full catalogue scrape.
{:else}
Ask an admin to run a catalogue scrape.
{/if}
@@ -495,11 +499,11 @@
<a
href="/books/{novel.slug}"
onclick={() => handleNovelClick(novel.slug)}
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 border transition-colors relative
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors relative
{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
>
<!-- Cover -->
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if novel.cover}
<img
src={novel.cover}
@@ -508,7 +512,7 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -516,19 +520,19 @@
</div>
{/if}
{#if novel.rank}
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-amber-400 font-bold">
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-brand) font-bold">
{novel.rank}
</span>
{/if}
{#if novel.rating}
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-300">
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-text)">
{novel.rating}
</span>
{/if}
<!-- Loading overlay -->
{#if isLoading}
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
<svg class="w-8 h-8 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
<svg class="w-8 h-8 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
@@ -538,11 +542,11 @@
<!-- Info -->
<div class="p-2 flex flex-col gap-1 flex-1">
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{novel.title}</h2>
<h2 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{novel.title}</h2>
{#if novel.author}
<p class="text-xs text-zinc-500 truncate">{novel.author}</p>
<p class="text-xs text-(--color-muted) truncate">{novel.author}</p>
{:else if novel.chapters}
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
<p class="text-xs text-(--color-muted) truncate">{novel.chapters}</p>
{/if}
<!-- Admin: per-novel scrape button -->
@@ -553,14 +557,14 @@
{:else if scrapeResult[novel.slug] === 'busy'}
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
{:else if scrapeResult[novel.slug] === 'forbidden'}
<span class="text-xs text-red-400 font-medium">Forbidden</span>
<span class="text-xs text-(--color-danger) font-medium">Forbidden</span>
{:else if scrapeResult[novel.slug] === 'error'}
<span class="text-xs text-red-400 font-medium">Error</span>
<span class="text-xs text-(--color-danger) font-medium">Error</span>
{:else}
<button
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
disabled={scraping[novel.slug]}
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
>
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
</button>
@@ -578,20 +582,20 @@
{#each novels as novel}
{@const isLoading = loadingSlug === novel.slug}
<div
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
class="flex items-center gap-4 bg-(--color-surface-2) border rounded-lg px-4 py-3 transition-colors
{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
>
<!-- Rank / index -->
{#if novel.rank}
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
<span class="text-(--color-brand) font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
{/if}
<!-- Cover thumbnail -->
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900 relative">
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
{#if novel.cover}
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -599,8 +603,8 @@
</div>
{/if}
{#if isLoading}
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
<svg class="w-4 h-4 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
<svg class="w-4 h-4 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
@@ -615,28 +619,28 @@
href="/books/{novel.slug}"
onclick={() => handleNovelClick(novel.slug)}
class="text-sm font-semibold transition-colors line-clamp-1
{isLoading ? 'text-amber-400' : 'text-zinc-100 hover:text-amber-400'}"
{isLoading ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
>
{novel.title}
</a>
{:else}
<span class="text-sm font-semibold text-zinc-100 line-clamp-1">{novel.title}</span>
<span class="text-sm font-semibold text-(--color-text) line-clamp-1">{novel.title}</span>
{/if}
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
{#if novel.author}
<span class="text-xs text-zinc-400">{novel.author}</span>
<span class="text-xs text-(--color-muted)">{novel.author}</span>
{/if}
{#if novel.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{novel.status}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text)">{novel.status}</span>
{:else if novel.chapters}
<span class="text-xs text-zinc-500">{novel.chapters}</span>
<span class="text-xs text-(--color-muted)">{novel.chapters}</span>
{/if}
{#if novel.rating}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">{novel.rating}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">{novel.rating}</span>
{/if}
{#if novel.genres?.length}
{#each novel.genres.slice(0, 3) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
{/if}
</div>
@@ -650,12 +654,12 @@
{:else if scrapeResult[novel.slug] === 'busy'}
<span class="text-xs text-yellow-400 font-medium">Busy</span>
{:else if scrapeResult[novel.slug] === 'error'}
<span class="text-xs text-red-400 font-medium">Error</span>
<span class="text-xs text-(--color-danger) font-medium">Error</span>
{:else}
<button
onclick={() => scrapeNovel(novel)}
disabled={scraping[novel.slug]}
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
>
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
</button>
@@ -669,7 +673,7 @@
href={novel.source_url ?? novel.url}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
class="shrink-0 text-(--color-muted) hover:text-(--color-text) transition-colors"
title="Open on novelfire.net"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -693,13 +697,13 @@
<!-- Loading spinner while fetching next page -->
{#if loadingMore}
<div class="flex justify-center py-8">
<svg class="w-6 h-6 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
{:else if !hasNext && novels.length > 0}
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">All novels loaded</p>
{/if}
{/if}
@@ -707,7 +711,7 @@
{#if showScrollTop}
<button
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 shadow-lg hover:bg-zinc-700 hover:text-zinc-100 transition-colors"
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-text) shadow-lg hover:bg-(--color-surface-3) hover:text-(--color-text) transition-colors"
title="Back to top"
aria-label="Scroll to top"
>

View File

@@ -3,12 +3,12 @@
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Disclaimer</h1>
<h1 class="text-2xl font-bold text-(--color-text) mb-6">Disclaimer</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<div class="space-y-5 text-sm text-(--color-muted) leading-relaxed">
<p>
libnovel is a personal reading tool that indexes and caches publicly accessible novel content
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>.
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">novelfire.net</a>.
It is not affiliated with, endorsed by, or in any way officially connected to those sources.
</p>
@@ -20,7 +20,7 @@
<p>
If you are a rights holder and believe your work is being used without authorisation, please
refer to our <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>
refer to our <a href="/dmca" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">DMCA policy</a>
for instructions on how to request removal.
</p>
@@ -29,6 +29,6 @@
content displayed. Use of this site is at your own risk.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
<p class="text-(--color-muted) text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -3,16 +3,16 @@
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">DMCA Takedown Policy</h1>
<h1 class="text-2xl font-bold text-(--color-text) mb-6">DMCA Takedown Policy</h1>
<div class="prose-zinc space-y-5 text-sm text-zinc-400 leading-relaxed">
<div class="prose-zinc space-y-5 text-sm text-(--color-muted) leading-relaxed">
<p>
libnovel respects the intellectual property rights of authors, publishers, and other content
creators. If you believe that content available through this site infringes your copyright,
please send a written takedown notice to the contact address below.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Your notice must include</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Your notice must include</h2>
<ol class="list-decimal list-inside space-y-2 pl-1">
<li>Your full legal name and contact information (email address).</li>
<li>A description of the copyrighted work you claim has been infringed.</li>
@@ -28,18 +28,18 @@
<li>Your electronic or physical signature.</li>
</ol>
<h2 class="text-base font-semibold text-zinc-200 mt-6">How to submit</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">How to submit</h2>
<p>
Send your notice by email to <span class="text-zinc-300 font-medium">dmca@libnovel.local</span>.
Send your notice by email to <span class="text-(--color-text) font-medium">dmca@libnovel.local</span>.
We will review valid notices and remove or disable access to the identified content promptly.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Counter-notices</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Counter-notices</h2>
<p>
If you believe content was removed in error, you may submit a counter-notice to the same
address with the information required under 17 U.S.C. § 512(g)(3).
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
<p class="text-(--color-muted) text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -18,12 +18,12 @@
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-zinc-100 mb-2">Sign in to libnovel</h1>
<p class="text-sm text-zinc-400">Choose a provider to continue</p>
<h1 class="text-2xl font-bold text-(--color-text) mb-2">Sign in to libnovel</h1>
<p class="text-sm text-(--color-muted)">Choose a provider to continue</p>
</div>
{#if data.error && errorMessages[data.error]}
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
<div class="mb-6 rounded bg-(--color-danger)/10 border border-(--color-danger) px-4 py-3 text-sm text-(--color-danger)">
{errorMessages[data.error]}
</div>
{/if}
@@ -33,8 +33,8 @@
<a
href="/auth/google"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
>
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path
@@ -61,10 +61,10 @@
<a
href="/auth/github"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
>
<svg class="w-5 h-5 shrink-0 fill-zinc-100" viewBox="0 0 24 24" aria-hidden="true">
<svg class="w-5 h-5 shrink-0 fill-(--color-text)" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483
0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466
@@ -80,7 +80,7 @@
</a>
</div>
<p class="mt-8 text-center text-xs text-zinc-500">
<p class="mt-8 text-center text-xs text-(--color-muted)">
By signing in you agree to our terms of service.
</p>
</div>

View File

@@ -3,53 +3,53 @@
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Privacy Policy</h1>
<h1 class="text-2xl font-bold text-(--color-text) mb-6">Privacy Policy</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<div class="space-y-5 text-sm text-(--color-muted) leading-relaxed">
<p>
This policy describes what limited data libnovel collects and how it is used.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data we collect</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Data we collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>
<span class="text-zinc-300">Session cookies</span> — a short-lived cookie is set when you
<span class="text-(--color-text)">Session cookies</span> — a short-lived cookie is set when you
visit the site to track reading progress across pages. No account is required.
</li>
<li>
<span class="text-zinc-300">Account data (optional)</span> — if you create an account,
<span class="text-(--color-text)">Account data (optional)</span> — if you create an account,
we store your username and a hashed password. No email address is required.
</li>
<li>
<span class="text-zinc-300">Reading progress</span> — the last chapter you read for each
<span class="text-(--color-text)">Reading progress</span> — the last chapter you read for each
book is stored server-side, tied to your session or account, so you can resume reading.
</li>
<li>
<span class="text-zinc-300">Saved books</span> — books you explicitly bookmark are stored
<span class="text-(--color-text)">Saved books</span> — books you explicitly bookmark are stored
server-side tied to your session or account.
</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">What we do not collect</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">What we do not collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>No email addresses (unless you choose to provide one).</li>
<li>No tracking pixels, analytics scripts, or third-party ad networks.</li>
<li>No selling or sharing of data with third parties.</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Third-party content</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Third-party content</h2>
<p>
Cover images and chapter content are fetched from third-party sources (e.g.
<a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>).
<a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">novelfire.net</a>).
Your browser may make requests directly to those domains when loading images.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data deletion</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Data deletion</h2>
<p>
You can delete your reading progress and saved books from your profile page at any time.
To request full account deletion, contact us via the <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">contact address listed in our DMCA policy</a>.
To request full account deletion, contact us via the <a href="/dmca" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">contact address listed in our DMCA policy</a>.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
<p class="text-(--color-muted) text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
@@ -16,13 +16,11 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
// Fetch avatar presigned URL if user has one
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
if (record?.avatar_url) {
avatarUrl = await presignAvatarUrl(locals.user.id);
}
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack } from 'svelte';
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
@@ -89,6 +89,16 @@
autoNext = audioStore.autoNext;
});
// ── Theme ────────────────────────────────────────────────────────────────────
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
const THEMES: { id: string; label: string; swatch: string }[] = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
@@ -99,12 +109,14 @@
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed })
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
// Apply theme live via context
themeCtx?.setTheme(selectedTheme);
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
@@ -214,15 +226,15 @@
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
title="Change profile picture"
disabled={avatarUploading}
>
{#if avatarUrl}
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</div>
@@ -252,34 +264,64 @@
</div>
<div>
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
{#if avatarError}
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
<p class="text-(--color-muted) text-xs mt-1">Click avatar to change photo</p>
{/if}
</div>
</div>
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">Appearance</h2>
<div class="space-y-2">
<p class="text-sm font-medium text-(--color-text)">Theme</p>
<div class="flex gap-3 flex-wrap">
{#each THEMES as t}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
class="flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedTheme === t.id}
>
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
{t.label}
{#if selectedTheme === t.id}
<svg class="w-3 h-3 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>
{/if}
</button>
{/each}
</div>
</div>
</section>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-5">
<h2 class="text-lg font-semibold text-zinc-100">Reading settings</h2>
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">Reading settings</h2>
<!-- Voice -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="voice-select">TTS voice</label>
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">TTS voice</label>
{#if !voicesLoaded}
<div class="h-9 bg-zinc-700 rounded animate-pulse"></div>
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-400 text-sm cursor-not-allowed">
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
<option>No voices available</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full bg-(--color-surface-3) 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)"
>
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
@@ -301,8 +343,8 @@
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="speed-range">
Playback speed — <span class="text-amber-400 font-mono">{speed.toFixed(1)}x</span>
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
Playback speed — <span class="text-(--color-brand) font-mono">{speed.toFixed(1)}x</span>
</label>
<input
id="speed-range"
@@ -311,9 +353,10 @@
max="3.0"
step="0.1"
bind:value={speed}
class="w-full accent-amber-400"
style="accent-color: var(--color-brand);"
class="w-full"
/>
<div class="flex justify-between text-xs text-zinc-500">
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span>
<span>3.0x</span>
</div>
@@ -324,16 +367,17 @@
<input
type="checkbox"
bind:checked={autoNext}
class="w-4 h-4 rounded accent-amber-400"
style="accent-color: var(--color-brand);"
class="w-4 h-4 rounded"
/>
<span class="text-sm text-zinc-300">Auto-advance to next chapter</span>
<span class="text-sm text-(--color-text)">Auto-advance to next chapter</span>
</label>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-60"
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
>
{settingsSaving ? 'Saving…' : 'Save settings'}
</button>
@@ -344,33 +388,33 @@
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Active sessions</h2>
<p class="text-sm text-zinc-400">These are all devices currently signed into your account. End any session you don't recognise.</p>
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">Active sessions</h2>
<p class="text-sm text-(--color-muted)">These are all devices currently signed into your account. End any session you don't recognise.</p>
{#if revokeError}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{revokeError}
</div>
{/if}
{#if sessions.length === 0}
<p class="text-sm text-zinc-500 italic">No session records found. Sessions are tracked from the next login.</p>
<p class="text-sm text-(--color-muted) italic">No session records found. Sessions are tracked from the next login.</p>
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-amber-400/10 border border-amber-400/30' : 'bg-zinc-700/50 border border-zinc-600/50'}">
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-(--color-brand)/10 border border-(--color-brand)/30' : 'bg-(--color-surface-3)/50 border border-(--color-border)/50'}">
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-100 truncate">{parseUA(session.user_agent)}</span>
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
{#if session.is_current}
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/40">This session</span>
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">This session</span>
{/if}
</div>
{#if session.ip}
<p class="text-xs text-zinc-400 font-mono">{session.ip}</p>
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
{/if}
<p class="text-xs text-zinc-500">
<p class="text-xs text-(--color-muted)">
Signed in {formatDate(session.created_at)}
{#if session.last_seen && session.last_seen !== session.created_at}
· Last seen {formatDate(session.last_seen)}
@@ -382,8 +426,8 @@
disabled={revokingId === session.id}
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-red-900/40 text-red-300 border border-red-700/60 hover:bg-red-900/70'
: 'bg-zinc-600/60 text-zinc-300 border border-zinc-500/50 hover:bg-zinc-600'}"
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
>
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
</button>
@@ -394,11 +438,11 @@
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">Change password</h2>
{#if form?.error}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{form.error}
</div>
{/if}
@@ -422,42 +466,42 @@
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="current">Current password</label>
<label class="block text-sm font-medium text-(--color-text)" for="current">Current password</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full bg-(--color-surface-3) 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 class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="next">New password</label>
<label class="block text-sm font-medium text-(--color-text)" for="next">New password</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full bg-(--color-surface-3) 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 class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="confirm">Confirm new password</label>
<label class="block text-sm font-medium text-(--color-text)" for="confirm">Confirm new password</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
class="w-full bg-(--color-surface-3) 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>
<button
type="submit"
disabled={pwSubmitting}
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-60"
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
>
{pwSubmitting ? 'Updating…' : 'Update password'}
</button>

View File

@@ -3,14 +3,14 @@
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Terms of Service</h1>
<h1 class="text-2xl font-bold text-(--color-text) mb-6">Terms of Service</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<div class="space-y-5 text-sm text-(--color-muted) leading-relaxed">
<p>
By using libnovel you agree to these terms. If you do not agree, please do not use the service.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Use of the service</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Use of the service</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>libnovel is provided for personal, non-commercial reading use only.</li>
<li>You may not scrape, crawl, or systematically download content from the site.</li>
@@ -18,34 +18,34 @@
<li>Accounts may be suspended or terminated for abuse.</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Content</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Content</h2>
<p>
libnovel aggregates publicly available web novel content from third-party sources for
personal reading convenience. We do not claim ownership of any novel content displayed on
the site. If you are a rights holder and wish to have content removed, please see our
<a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>.
<a href="/dmca" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">DMCA policy</a>.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Accounts</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Accounts</h2>
<p>
You are responsible for maintaining the security of your account. libnovel is not liable
for any loss or damage resulting from unauthorised access to your account.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Disclaimer of warranties</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Disclaimer of warranties</h2>
<p>
The service is provided "as is" without warranty of any kind. We do not guarantee
availability, accuracy, or completeness of any content. See our full
<a href="/disclaimer" class="text-amber-400 hover:text-amber-300 transition-colors">disclaimer</a>
<a href="/disclaimer" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">disclaimer</a>
for details.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Changes to these terms</h2>
<h2 class="text-base font-semibold text-(--color-text) mt-6">Changes to these terms</h2>
<p>
We may update these terms at any time. Continued use of the service after changes are
posted constitutes acceptance of the revised terms.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
<p class="text-(--color-muted) text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import {
getUserPublicLibrary,
getUserCurrentlyReading
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
@@ -15,11 +15,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
const profile = await getPublicProfile(username).catch(() => null);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar
// Resolve avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
// Subscription state for the logged-in visitor
let isSubscribed = false;

View File

@@ -61,10 +61,10 @@
<img
src={data.avatarUrl}
alt={data.profile.username}
class="w-20 h-20 rounded-full object-cover ring-2 ring-zinc-700"
class="w-20 h-20 rounded-full object-cover ring-2 ring-(--color-border)"
/>
{:else}
<div class="w-20 h-20 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-bold text-zinc-300 ring-2 ring-zinc-600">
<div class="w-20 h-20 rounded-full bg-(--color-surface-3) flex items-center justify-center text-2xl font-bold text-(--color-text) ring-2 ring-(--color-border)">
{initials(data.profile.username)}
</div>
{/if}
@@ -72,18 +72,18 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-zinc-100 mb-0.5">{data.profile.username}</h1>
<p class="text-xs text-zinc-500 mb-3">Joined {joinDate(data.profile.created)}</p>
<h1 class="text-xl font-bold text-(--color-text) mb-0.5">{data.profile.username}</h1>
<p class="text-xs text-(--color-muted) mb-3">Joined {joinDate(data.profile.created)}</p>
<!-- Stats row -->
<div class="flex gap-5 text-sm mb-4">
<span>
<span class="font-semibold text-zinc-100">{followerCount}</span>
<span class="text-zinc-500 ml-1">followers</span>
<span class="font-semibold text-(--color-text)">{followerCount}</span>
<span class="text-(--color-muted) ml-1">followers</span>
</span>
<span>
<span class="font-semibold text-zinc-100">{data.profile.followingCount}</span>
<span class="text-zinc-500 ml-1">following</span>
<span class="font-semibold text-(--color-text)">{data.profile.followingCount}</span>
<span class="text-(--color-muted) ml-1">following</span>
</span>
</div>
@@ -94,8 +94,8 @@
disabled={subLoading}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50
{subscribed
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 border border-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 border border-(--color-border)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)'}"
>
{#if subLoading}
@@ -108,7 +108,7 @@
{:else if !data.isLoggedIn}
<a
href="/login"
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-amber-400 text-zinc-900 hover:bg-amber-300 transition-colors"
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
>
Follow
</a>
@@ -119,14 +119,14 @@
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
{#if data.currentlyReading.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">Currently Reading</h2>
<h2 class="text-base font-semibold text-(--color-text) mb-3">Currently Reading</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.currentlyReading as { book, chapter }}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
@@ -135,20 +135,20 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
@@ -160,18 +160,18 @@
<!-- ── Library ───────────────────────────────────────────────────────────── -->
{#if data.library.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">
<h2 class="text-base font-semibold text-(--color-text) mb-3">
Library
<span class="text-zinc-500 font-normal text-sm ml-1">({data.library.length})</span>
<span class="text-(--color-muted) font-normal text-sm ml-1">({data.library.length})</span>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.library as { book, chapter, saved }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
@@ -180,32 +180,32 @@
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
{#if chapter}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-zinc-900/80 text-zinc-300 font-medium px-1.5 py-0.5 rounded">
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-surface)/80 text-(--color-text) font-medium px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
{/if}
{#if saved && !chapter}
<span class="absolute top-1.5 right-1.5">
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3a2 2 0 00-2 2v16l9-4 9 4V5a2 2 0 00-2-2H5z"/>
</svg>
</span>
{/if}
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
{#if genres.length > 0}
<p class="text-xs text-zinc-600 truncate mt-0.5">{genres[0]}</p>
<p class="text-xs text-(--color-muted) truncate mt-0.5">{genres[0]}</p>
{/if}
</div>
</a>
@@ -216,8 +216,8 @@
<!-- ── Empty state ───────────────────────────────────────────────────────── -->
{#if data.library.length === 0 && data.currentlyReading.length === 0}
<div class="py-16 text-center text-zinc-500">
<svg class="w-10 h-10 mx-auto mb-3 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="py-16 text-center text-(--color-muted)">
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="text-sm">No books in library yet.</p>