Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2a4fc755 | ||
|
|
801928aadf | ||
|
|
040072c3f5 | ||
|
|
6a76e97a67 | ||
|
|
71f79c8e02 | ||
|
|
5ee4a06654 | ||
|
|
63b286d0a4 | ||
|
|
d3f06c5c40 | ||
|
|
e71ddc2f8b | ||
|
|
b783dae5f4 | ||
|
|
dcf40197d4 | ||
|
|
9dae5e7cc0 | ||
|
|
908f5679fd | ||
|
|
f75292f531 | ||
|
|
2cf0528730 | ||
|
|
428b57732e | ||
|
|
61e77e3e28 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
61
Caddyfile
61
Caddyfile
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
51
caddy/errors/404.html
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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(Go — HTTP 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 |
@@ -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
3
ui/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">·</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">·</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">·</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">·</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,4 +41,5 @@ export interface UserSettings {
|
||||
voice: string;
|
||||
speed: number;
|
||||
autoNext: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>© {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"/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
{#if stats.failed > 0}
|
||||
· <span class="text-red-400">{stats.failed} failed</span>
|
||||
· <span class="text-(--color-danger)">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
|
||||
· <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{/if}
|
||||
· {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}
|
||||
|
||||
26
ui/src/routes/admin/changelog/+page.server.ts
Normal file
26
ui/src/routes/admin/changelog/+page.server.ts
Normal 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) };
|
||||
}
|
||||
};
|
||||
59
ui/src/routes/admin/changelog/+page.svelte
Normal file
59
ui/src/routes/admin/changelog/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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} →
|
||||
</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"
|
||||
>
|
||||
← 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 →
|
||||
</a>
|
||||
|
||||
@@ -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 & status filters apply to Browse only</p>
|
||||
<p class="text-xs text-(--color-muted) italic">Genre & 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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user