Compare commits

...

16 Commits

Author SHA1 Message Date
Admin
c6597c8d19 feat(home): hero resume card, horizontal scroll rows, genre strip, dedup
Some checks failed
CI / Backend (push) Successful in 1m0s
CI / UI (push) Failing after 26s
Release / Test backend (push) Successful in 53s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m13s
CI / UI (pull_request) Failing after 33s
Release / Docker / runner (push) Failing after 1m27s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Failing after 31s
Release / Docker / ui (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- First continue-reading book becomes a wide hero card with title,
  description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
  chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:20:28 +05:00
Admin
e8d7108753 feat(themes): add light, light-slate, and light-rose themes
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Failing after 11s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
CI / UI (push) Successful in 35s
Release / Docker / caddy (push) Successful in 58s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 31s
CI / UI (pull_request) Successful in 1m1s
Release / Docker / ui (push) Failing after 2m43s
Release / Gitea Release (push) Has been skipped
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:13:25 +05:00
Admin
90dbecfa17 feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
- Remove both "Save settings" buttons; all settings now auto-save with
  800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
  change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
  with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
  color) for cleaner readout
- Auto-advance toggle gains a subtitle description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:07:51 +05:00
Admin
2deb306419 fix(i18n+settings): rename pt-BR→pt, fix theme/locale persistence
Some checks failed
CI / Backend (push) Successful in 56s
CI / UI (push) Successful in 38s
Release / Test backend (push) Successful in 42s
Release / Docker / caddy (push) Failing after 11s
CI / Backend (pull_request) Failing after 11s
Release / Docker / backend (push) Failing after 38s
CI / UI (pull_request) Successful in 44s
Release / Check ui (push) Successful in 1m53s
Release / Docker / runner (push) Failing after 1m26s
Release / Docker / ui (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
Root cause: user_settings table was missing theme, locale, font_family,
font_size columns — PocketBase silently dropped them on every save.
Added the four columns via PocketBase API.

Also:
- listOne now sorts by -updated so the most-recent settings record wins
- PARAGLIDE_LOCALE cookie is now cleared when switching back to English
- pt-BR renamed to pt throughout (messages, inlang settings, validLocales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:05:14 +05:00
Admin
fd283bf6c6 fix(sessions): prune stale sessions on login to prevent accumulation
Some checks failed
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 36s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 40s
CI / UI (pull_request) Failing after 37s
CI / Backend (pull_request) Successful in 48s
Release / Docker / runner (push) Failing after 32s
Release / Docker / backend (push) Successful in 2m39s
Release / Docker / ui (push) Successful in 1m45s
Release / Gitea Release (push) Has been skipped
Sessions not seen in 30+ days are deleted in the background each time
a new session is created. No cron job needed — self-cleaning on login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:24:16 +05:00
Admin
3154a22500 fix(errors): redesign all Caddy error pages with consistent branded layout
Some checks failed
CI / UI (push) Successful in 43s
Release / Test backend (push) Successful in 45s
CI / Backend (push) Successful in 48s
Release / Check ui (push) Successful in 36s
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 36s
Release / Docker / caddy (push) Failing after 1m50s
Release / Docker / runner (push) Successful in 1m57s
Release / Docker / backend (push) Successful in 3m13s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Has been skipped
Add header/footer, LibNovel wordmark, pulsing status indicator, faint
watermark code, and auto-refresh countdown (20s for 502/504, 30s for 503).
Fix two-tone background by setting background on html+body. 404 is static
with no auto-refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:22:14 +05:00
Admin
61e0d98057 feat(ui): declutter header — user menu + lang dropdown, mobile theme/lang
Some checks failed
CI / Backend (pull_request) Successful in 55s
CI / UI (pull_request) Successful in 49s
CI / UI (push) Failing after 35s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 57s
Release / Check ui (push) Successful in 36s
Release / Docker / caddy (push) Successful in 1m11s
Release / Docker / runner (push) Successful in 2m5s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Failing after 2m32s
Release / Gitea Release (push) Has been skipped
Desktop header before: logo · Library · Catalogue · Feedback · ●●● · EN RU ID PT-BR FR · Admin · username · Sign out
Desktop header after:  logo · Library · Catalogue · Feedback · ●●● · [🌐 EN ▾] · [avatar ▾]

Changes:
- Theme dots kept but made slightly smaller (3.5 → 3.5, less gap)
- 5 bare language codes replaced with a compact globe + current locale
  dropdown (click to expand all 5 options)
- Admin link, username, and Sign out collapsed into a single user-initial
  avatar dropdown (Profile / Admin panel / Sign out)
- Click-outside overlay closes any open dropdown
- Mobile drawer: added Theme and Language rows so users can switch both
  without opening the desktop header
- Footer locale switcher removed (redundant with header and mobile drawer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:54:46 +05:00
Admin
601c26d436 fix(ui): theme token consistency, seek bar a11y, colour polish
All checks were successful
CI / Backend (push) Successful in 36s
CI / Backend (pull_request) Successful in 46s
CI / UI (push) Successful in 59s
CI / UI (pull_request) Successful in 37s
- Add --color-success variable to all three themes
- Replace hard-coded amber/green Tailwind colours with CSS variables:
    Pro badge + discount badge: bg-amber-400/15 → bg-(--color-brand)/15
    Saved confirmation:         text-green-400 → text-(--color-success)
    Catalogue flash messages:   emerald/yellow → success/brand tokens
    Login hover border:         border-zinc-600 → border-(--color-brand)/50
- Seek bar in mini-player now has aria-label (player_seek_label)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:24:34 +05:00
Admin
4a267d8fd8 feat: open catalogue + book pages to public (hybrid open model)
All checks were successful
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 44s
CI / Backend (push) Successful in 44s
Release / Test backend (push) Successful in 28s
CI / UI (push) Successful in 1m12s
Release / Check ui (push) Successful in 56s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / ui (push) Successful in 1m57s
Release / Docker / runner (push) Successful in 3m45s
Release / Docker / backend (push) Successful in 3m50s
Release / Gitea Release (push) Successful in 14s
Reading content is now publicly accessible without login:
- /catalogue, /books/[slug], /books/[slug]/chapters, /books/[slug]/chapters/[n]
  are all public — no login redirect
- /books (personal library), /profile, /admin remain protected

Unauthenticated UX:
- Chapter page: "Audio narration available — Sign in to listen" banner
  replaces the audio player for logged-out visitors
- Book detail: bookmark icon links to /login instead of triggering
  the save action (both desktop and mobile CTAs)

SEO:
- robots.txt updated: Allow /books/ and /catalogue, Disallow /books (library)
- sitemap.xml now includes /catalogue as primary crawl entry point

i18n: added reader_signin_for_audio, reader_signin_audio_desc,
      book_detail_signin_to_save in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:16:22 +05:00
Admin
c9478a67fb fix(ci): use full gitea.com URL for gitea-release-action
All checks were successful
CI / UI (push) Successful in 41s
CI / Backend (push) Successful in 50s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 34s
CI / Backend (pull_request) Successful in 46s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 3m29s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / caddy (push) Successful in 1m17s
Release / Gitea Release (push) Successful in 17s
Bare `actions/` references resolve to github.com by default in act_runner.
gitea-release-action lives on gitea.com so must use the full https:// URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:38:36 +05:00
Admin
1b4835daeb fix(homelab): switch Fider SMTP to port 587 + STARTTLS
All checks were successful
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 1m10s
Port 465 (SMTPS) is blocked on the homelab server. Port 587 with STARTTLS
works. Updated FIDER_SMTP_PORT=587 and FIDER_SMTP_ENABLE_STARTTLS=true in
Doppler prd_homelab, and made EMAIL_SMTP_ENABLE_STARTTLS dynamic so it reads
from Doppler instead of being hardcoded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:16:01 +05:00
Admin
c9c12fc4a8 fix(infra): correct doppler entrypoint for watchtower container
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 45s
- Fix binary path: /usr/bin/doppler (not /usr/local/bin)
- Mount /root/.doppler config so the container can auth without DOPPLER_TOKEN env
- Set HOME=/root so doppler locates the mounted config directory
- Add explicit --project/--config flags to override directory-scope lookup
- Production: --project libnovel --config prd
- Homelab: --project libnovel --config prd_homelab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:05:33 +05:00
Admin
dd35024d02 chore(infra): run watchtower via doppler for fresh secrets on restart
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 47s
Mount the host doppler binary into the watchtower container and use it as
the entrypoint so WATCHTOWER_NOTIFICATION_URL and other secrets are fetched
from Doppler each time the container starts, rather than being baked in at
compose-up time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:52:32 +05:00
Admin
4b8104f087 feat(ui): language persistence, theme fix, font/size settings, header quick-access
Some checks failed
CI / UI (push) Failing after 54s
CI / Backend (push) Successful in 1m56s
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 54s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 2m3s
Release / Gitea Release (push) Failing after 2s
- Fix language not persisting after refresh: save locale in user_settings,
  set PARAGLIDE_LOCALE cookie from DB preference on server load
- Fix theme change not applying: context setter was mismatched (setTheme vs current)
- Add font family (system/serif/mono) and text size (sm/md/lg/xl) user settings
  stored in DB and applied via CSS custom properties
- Add theme color dots and language picker to desktop header for quick access
- Footer locale switcher now saves preference to DB before switching
- Remove change password section (OAuth-only, no password login)
- Fix active sessions piling up: reuse existing session on re-login via OAuth
- Extend speed step cycle to include 2.5× and 3.0× (matching profile slider)
- Replace plain checkbox with modern toggle switch for auto-next setting
- Fix catalogue status labels (ongoing/completed) to use translation keys
- Add font family and text size translation keys to all 5 locale files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:43:00 +05:00
Admin
5da880d189 fix(runner): add heartbeat + translation polling to asynq mode
All checks were successful
CI / UI (push) Successful in 57s
CI / Backend (pull_request) Successful in 1m4s
CI / Backend (push) Successful in 1m45s
CI / UI (pull_request) Successful in 51s
Two bugs prevented asynq mode from working correctly on the homelab runner:

1. No healthcheck file: asynq mode never writes /tmp/runner.alive, so
   Docker healthcheck always fails. Added heartbeat goroutine that
   writes the file every StaleTaskThreshold (30s).

2. Translation tasks not dispatched: translation uses ClaimNextTranslationTask
   (PocketBase poll queue), not Redis/asynq. Audio + scrape use asynq mux,
   but translation sits in PocketBase forever. Added pollTranslationTasks()
   goroutine that polls PocketBase on the same PollInterval as the old
   poll() loop.

All Go tests pass (go test ./... in backend/).
2026-03-29 20:22:37 +05:00
Admin
98631df47a feat(billing): Polar.sh Pro subscription integration
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
  subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
  counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
  or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
2026-03-29 13:21:23 +05:00
39 changed files with 2073 additions and 643 deletions

View File

@@ -279,7 +279,7 @@ jobs:
fetch-depth: 0
- name: Create release
uses: actions/gitea-release-action@v1
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
generate_release_notes: true

View File

@@ -13,6 +13,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/hibiken/asynq"
@@ -72,6 +74,44 @@ func (r *Runner) runAsynq(ctx context.Context) error {
r.deps.Log.Info("runner: asynq mode active", "redis_addr", r.cfg.RedisAddr)
// ── Heartbeat goroutine ──────────────────────────────────────────────
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
// This mirrors the heartbeat file behavior from the poll() loop.
go func() {
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
defer heartbeatTick.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeatTick.C:
if f, err := os.Create("/tmp/runner.alive"); err != nil {
r.deps.Log.Warn("runner: could not write heartbeat file", "err", err)
} else {
f.Close()
}
}
}
}()
// ── Translation polling goroutine ────────────────────────────────────
// Translation tasks live in PocketBase (not Redis), so we need a separate
// poll loop to claim and dispatch them. This runs alongside the Asynq server.
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
var translationWg sync.WaitGroup
go func() {
tick := time.NewTicker(r.cfg.PollInterval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
r.pollTranslationTasks(ctx, translationSem, &translationWg)
}
}
}()
// Run catalogue refresh ticker in the background.
go func() {
for {
@@ -93,6 +133,9 @@ func (r *Runner) runAsynq(ctx context.Context) error {
<-ctx.Done()
r.deps.Log.Info("runner: context cancelled, shutting down asynq server")
srv.Shutdown()
// Wait for translation tasks to complete.
translationWg.Wait()
return nil
}
@@ -147,3 +190,47 @@ func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
r.runAudioTask(ctx, task)
return nil
}
// pollTranslationTasks claims all available translation tasks from PocketBase
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
// because they're stored in PocketBase, so we need this separate poll loop.
func (r *Runner) pollTranslationTasks(ctx context.Context, translationSem chan struct{}, wg *sync.WaitGroup) {
// Reap orphaned tasks (same logic as poll() in runner.go).
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
r.deps.Log.Warn("runner: reap stale translation tasks failed", "err", err)
} else if n > 0 {
r.deps.Log.Info("runner: reaped stale translation tasks", "count", n)
}
translationLoop:
for {
if ctx.Err() != nil {
return
}
select {
case translationSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break translationLoop
}
task, ok, err := r.deps.Consumer.ClaimNextTranslationTask(ctx, r.cfg.WorkerID)
if err != nil {
<-translationSem
r.deps.Log.Error("runner: ClaimNextTranslationTask failed", "err", err)
break
}
if !ok {
<-translationSem
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.TranslationTask) {
defer wg.Done()
defer func() { <-translationSem }()
defer r.tasksRunning.Add(-1)
r.runTranslationTask(ctx, t)
}(task)
}
}

View File

@@ -3,49 +3,137 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — Page Not Found</title>
<title>404 — Page Not Found — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
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;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
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;
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #71717a;
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #71717a;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
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>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<div class="watermark">404</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Page not found</span>
</div>
<h1>Nothing here</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a class="btn" href="/">Go home</a>
</main>
<footer>
&copy; LibNovel
</footer>
</body>
</html>

View File

@@ -3,49 +3,160 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>502 — Service Unavailable</title>
<title>502 — Service Unavailable — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
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;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
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;
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
a:hover { background: #d97706; }
</style>
</head>
<body>
<div class="code">502</div>
<h1>Service Unavailable</h1>
<p>The server is temporarily unreachable. Please try again in a moment.</p>
<a href="/">Go home</a>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<div class="watermark">502</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Service unavailable</span>
</div>
<h1>Something went wrong</h1>
<p>The server is temporarily unreachable. This usually resolves itself quickly.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body>
</html>

View File

@@ -3,49 +3,163 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>503 — Maintenance</title>
<title>Under Maintenance — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
/* ── Header ── */
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
/* ── Main ── */
main {
flex: 1;
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;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
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;
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
/* ── Footer ── */
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
a:hover { background: #d97706; }
</style>
</head>
<body>
<div class="code">503</div>
<h1>Under Maintenance</h1>
<p>LibNovel is briefly offline for maintenance. We&rsquo;ll be back shortly.</p>
<a href="/">Try again</a>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<div class="watermark">503</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Maintenance in progress</span>
</div>
<h1>We'll be right back</h1>
<p>LibNovel is briefly offline for scheduled maintenance. No data is being changed — hang tight.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">30</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 30;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body>
</html>

View File

@@ -3,49 +3,160 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>504 — Gateway Timeout</title>
<title>504 — Gateway Timeout — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
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;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
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;
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
a:hover { background: #d97706; }
</style>
</head>
<body>
<div class="code">504</div>
<h1>Gateway Timeout</h1>
<p>The request took too long to complete. Please refresh and try again.</p>
<a href="/">Go home</a>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<div class="watermark">504</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Gateway timeout</span>
</div>
<h1>Request timed out</h1>
<p>The server took too long to respond. Please refresh and try again.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body>
</html>

View File

@@ -401,15 +401,19 @@ services:
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
# Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded.
# doppler binary is mounted from the host so watchtower fetches fresh secrets
# on every start (notification URL, credentials) without baking them in.
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --label-enable --interval 300 --cleanup
- /usr/bin/doppler:/usr/bin/doppler:ro
- /root/.doppler:/root/.doppler:ro
environment:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
HOME: "/root"
DOCKER_API_VERSION: "1.44"
volumes:

View File

@@ -221,7 +221,7 @@ services:
EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
EMAIL_SMTP_ENABLE_STARTTLS: "false"
EMAIL_SMTP_ENABLE_STARTTLS: "${FIDER_SMTP_ENABLE_STARTTLS}"
OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
@@ -443,15 +443,19 @@ services:
# ── Watchtower ──────────────────────────────────────────────────────────────
# Auto-updates runner image when CI pushes a new tag.
# Only watches services with the watchtower label.
# doppler binary is mounted from the host so watchtower fetches fresh secrets
# on every start (notification URL, credentials) without baking them in.
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd_homelab", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --label-enable --interval 300 --cleanup
- /usr/bin/doppler:/usr/bin/doppler:ro
- /root/.doppler:/root/.doppler:ro
environment:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
HOME: "/root"
DOCKER_API_VERSION: "1.44"
volumes:

View File

@@ -272,5 +272,7 @@ add_field "app_users" "verification_token" "text"
add_field "app_users" "verification_token_exp" "text"
add_field "app_users" "oauth_provider" "text"
add_field "app_users" "oauth_id" "text"
add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
log "done"

View File

@@ -48,6 +48,7 @@
"player_play": "Play",
"player_pause": "Pause",
"player_speed_label": "Playback speed {speed}x",
"player_seek_label": "Chapter progress",
"player_change_speed": "Change playback speed",
"player_auto_next_on": "Auto-next on",
"player_auto_next_off": "Auto-next off",
@@ -99,6 +100,7 @@
"catalogue_results_count": "{n} results",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Sign in to save",
"book_detail_add_to_library": "Add to Library",
"book_detail_remove_from_library": "Remove from Library",
"book_detail_read_now": "Read Now",
@@ -123,6 +125,8 @@
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Play narration",
"reader_generating_audio": "Generating audio…",
"reader_signin_for_audio": "Audio narration available",
"reader_signin_audio_desc": "Sign in to listen to this chapter narrated by AI.",
"reader_audio_error": "Audio generation failed.",
"reader_prev_chapter": "Previous chapter",
"reader_next_chapter": "Next chapter",
@@ -156,6 +160,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Slate",
"profile_theme_rose": "Rose",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Reading settings",
"profile_voice_label": "Default voice",
"profile_speed_label": "Playback speed",
@@ -329,6 +336,18 @@
"profile_password_changed_ok": "Password changed successfully.",
"profile_playback_speed": "Playback speed \u2014 {speed}x",
"profile_subscription_heading": "Subscription",
"profile_plan_pro": "Pro",
"profile_plan_free": "Free",
"profile_pro_active": "Your Pro subscription is active.",
"profile_pro_perks": "Unlimited audio, all translation languages, and voice selection are enabled.",
"profile_manage_subscription": "Manage subscription",
"profile_upgrade_heading": "Upgrade to Pro",
"profile_upgrade_desc": "Unlock unlimited audio, translations in 4 languages, and voice selection.",
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",
"user_joined": "Joined {date}",
@@ -380,5 +399,15 @@
"reader_generate_samples": "Generate missing samples",
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
"reader_choose_voice": "Choose Voice",
"reader_generating_narration": "Generating narration\u2026"
"reader_generating_narration": "Generating narration\u2026",
"profile_font_family": "Font Family",
"profile_font_system": "System",
"profile_font_serif": "Serif",
"profile_font_mono": "Monospace",
"profile_text_size": "Text Size",
"profile_text_size_sm": "Small",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Large",
"profile_text_size_xl": "X-Large"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Lecture",
"player_pause": "Pause",
"player_speed_label": "Vitesse {speed}x",
"player_seek_label": "Progression du chapitre",
"player_change_speed": "Changer la vitesse",
"player_auto_next_on": "Suivant auto activé",
"player_auto_next_off": "Suivant auto désactivé",
@@ -99,6 +100,7 @@
"catalogue_results_count": "{n} résultats",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
"book_detail_add_to_library": "Ajouter à la bibliothèque",
"book_detail_remove_from_library": "Retirer de la bibliothèque",
"book_detail_read_now": "Lire maintenant",
@@ -123,6 +125,8 @@
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Lire la narration",
"reader_generating_audio": "Génération audio…",
"reader_signin_for_audio": "Narration audio disponible",
"reader_signin_audio_desc": "Connectez-vous pour écouter ce chapitre narré par l'IA.",
"reader_audio_error": "Échec de la génération audio.",
"reader_prev_chapter": "Chapitre précédent",
"reader_next_chapter": "Chapitre suivant",
@@ -156,6 +160,9 @@
"profile_theme_amber": "Ambre",
"profile_theme_slate": "Ardoise",
"profile_theme_rose": "Rose",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Paramètres de lecture",
"profile_voice_label": "Voix par défaut",
"profile_speed_label": "Vitesse de lecture",
@@ -329,6 +336,18 @@
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
"profile_playback_speed": "Vitesse de lecture — {speed}x",
"profile_subscription_heading": "Abonnement",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuit",
"profile_pro_active": "Votre abonnement Pro est actif.",
"profile_pro_perks": "Audio illimité, toutes les langues de traduction et la sélection de voix sont activées.",
"profile_manage_subscription": "Gérer l'abonnement",
"profile_upgrade_heading": "Passer au Pro",
"profile_upgrade_desc": "Débloquez l'audio illimité, les traductions en 4 langues et la sélection de voix.",
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
"user_joined": "Inscrit le {date}",
@@ -379,5 +398,15 @@
"reader_generate_samples": "Générer les échantillons manquants",
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
"reader_choose_voice": "Choisir une voix",
"reader_generating_narration": "Génération de la narration…"
"reader_generating_narration": "Génération de la narration…",
"profile_font_family": "Police",
"profile_font_system": "Système",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Taille du texte",
"profile_text_size_sm": "Petit",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Putar",
"player_pause": "Jeda",
"player_speed_label": "Kecepatan {speed}x",
"player_seek_label": "Kemajuan bab",
"player_change_speed": "Ubah kecepatan",
"player_auto_next_on": "Auto-lanjut aktif",
"player_auto_next_off": "Auto-lanjut nonaktif",
@@ -99,6 +100,7 @@
"catalogue_results_count": "{n} hasil",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Masuk untuk menyimpan",
"book_detail_add_to_library": "Tambah ke Perpustakaan",
"book_detail_remove_from_library": "Hapus dari Perpustakaan",
"book_detail_read_now": "Baca Sekarang",
@@ -123,6 +125,8 @@
"reader_page_title": "{title} — Bab.{n} — libnovel",
"reader_play_narration": "Putar narasi",
"reader_generating_audio": "Membuat audio…",
"reader_signin_for_audio": "Narasi audio tersedia",
"reader_signin_audio_desc": "Masuk untuk mendengarkan bab ini yang dinarasikan oleh AI.",
"reader_audio_error": "Pembuatan audio gagal.",
"reader_prev_chapter": "Bab sebelumnya",
"reader_next_chapter": "Bab berikutnya",
@@ -156,6 +160,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Abu-abu",
"profile_theme_rose": "Mawar",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Pengaturan membaca",
"profile_voice_label": "Suara default",
"profile_speed_label": "Kecepatan pemutaran",
@@ -329,6 +336,18 @@
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
"profile_subscription_heading": "Langganan",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratis",
"profile_pro_active": "Langganan Pro kamu aktif.",
"profile_pro_perks": "Audio tanpa batas, semua bahasa terjemahan, dan pilihan suara tersedia.",
"profile_manage_subscription": "Kelola langganan",
"profile_upgrade_heading": "Tingkatkan ke Pro",
"profile_upgrade_desc": "Buka audio tanpa batas, terjemahan dalam 4 bahasa, dan pilihan suara.",
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
"user_joined": "Bergabung {date}",
@@ -379,5 +398,15 @@
"reader_generate_samples": "Hasilkan sampel yang hilang",
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
"reader_choose_voice": "Pilih Suara",
"reader_generating_narration": "Membuat narasi…"
"reader_generating_narration": "Membuat narasi…",
"profile_font_family": "Jenis Font",
"profile_font_system": "Sistem",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Ukuran Teks",
"profile_text_size_sm": "Kecil",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Reproduzir",
"player_pause": "Pausar",
"player_speed_label": "Velocidade {speed}x",
"player_seek_label": "Progresso do capítulo",
"player_change_speed": "Mudar velocidade",
"player_auto_next_on": "Próximo automático ativado",
"player_auto_next_off": "Próximo automático desativado",
@@ -99,6 +100,7 @@
"catalogue_results_count": "{n} resultados",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Entre para salvar",
"book_detail_add_to_library": "Adicionar à Biblioteca",
"book_detail_remove_from_library": "Remover da Biblioteca",
"book_detail_read_now": "Ler Agora",
@@ -123,6 +125,8 @@
"reader_page_title": "{title} — Cap.{n} — libnovel",
"reader_play_narration": "Reproduzir narração",
"reader_generating_audio": "Gerando áudio…",
"reader_signin_for_audio": "Narração de áudio disponível",
"reader_signin_audio_desc": "Entre para ouvir este capítulo narrado por IA.",
"reader_audio_error": "Falha na geração de áudio.",
"reader_prev_chapter": "Capítulo anterior",
"reader_next_chapter": "Próximo capítulo",
@@ -156,6 +160,9 @@
"profile_theme_amber": "Âmbar",
"profile_theme_slate": "Ardósia",
"profile_theme_rose": "Rosa",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Configurações de leitura",
"profile_voice_label": "Voz padrão",
"profile_speed_label": "Velocidade de reprodução",
@@ -329,6 +336,18 @@
"profile_password_changed_ok": "Senha alterada com sucesso.",
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
"profile_subscription_heading": "Assinatura",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuito",
"profile_pro_active": "Sua assinatura Pro está ativa.",
"profile_pro_perks": "Áudio ilimitado, todos os idiomas de tradução e seleção de voz estão habilitados.",
"profile_manage_subscription": "Gerenciar assinatura",
"profile_upgrade_heading": "Assinar o Pro",
"profile_upgrade_desc": "Desbloqueie áudio ilimitado, traduções em 4 idiomas e seleção de voz.",
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
"user_joined": "Entrou em {date}",
@@ -379,5 +398,15 @@
"reader_generate_samples": "Gerar amostras ausentes",
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
"reader_choose_voice": "Escolher Voz",
"reader_generating_narration": "Gerando narração…"
"reader_generating_narration": "Gerando narração…",
"profile_font_family": "Fonte",
"profile_font_system": "Sistema",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Tamanho do texto",
"profile_text_size_sm": "Pequeno",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Воспроизвести",
"player_pause": "Пауза",
"player_speed_label": "Скорость {speed}x",
"player_seek_label": "Прогресс главы",
"player_change_speed": "Изменить скорость",
"player_auto_next_on": "Автопереход вкл.",
"player_auto_next_off": "Автопереход выкл.",
@@ -99,6 +100,7 @@
"catalogue_results_count": "{n} результатов",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
"book_detail_add_to_library": "В библиотеку",
"book_detail_remove_from_library": "Удалить из библиотеки",
"book_detail_read_now": "Читать",
@@ -123,6 +125,8 @@
"reader_page_title": "{title} — Гл.{n} — libnovel",
"reader_play_narration": "Воспроизвести озвучку",
"reader_generating_audio": "Генерация аудио…",
"reader_signin_for_audio": "Доступна аудионарративация",
"reader_signin_audio_desc": "Войдите, чтобы слушать эту главу в озвучке ИИ.",
"reader_audio_error": "Ошибка генерации аудио.",
"reader_prev_chapter": "Предыдущая глава",
"reader_next_chapter": "Следующая глава",
@@ -156,6 +160,9 @@
"profile_theme_amber": "Янтарь",
"profile_theme_slate": "Сланец",
"profile_theme_rose": "Роза",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"profile_reading_heading": "Настройки чтения",
"profile_voice_label": "Голос по умолчанию",
"profile_speed_label": "Скорость воспроизведения",
@@ -329,6 +336,18 @@
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
"profile_pro_active": "Ваша подписка Pro активна.",
"profile_pro_perks": "Безлимитное аудио, все языки перевода и выбор голоса доступны.",
"profile_manage_subscription": "Управление подпиской",
"profile_upgrade_heading": "Перейти на Pro",
"profile_upgrade_desc": "Разблокируйте безлимитное аудио, переводы на 4 языка и выбор голоса.",
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
"user_joined": "Зарегистрирован {date}",
@@ -379,5 +398,15 @@
"reader_generate_samples": "Сгенерировать недостающие образцы",
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…"
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
"profile_font_mono": "Моноширинный",
"profile_text_size": "Размер текста",
"profile_text_size_sm": "Маленький",
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой"
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "ru", "id", "pt-BR", "fr"],
"locales": ["en", "ru", "id", "pt", "fr"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
],

View File

@@ -9,7 +9,8 @@
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f3f46; /* zinc-700 */
--color-danger: #f87171; /* red-400 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
@@ -23,6 +24,7 @@
--color-text: #f4f4f5;
--color-border: #3f3f46;
--color-danger: #f87171;
--color-success: #4ade80;
}
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
@@ -36,6 +38,7 @@
--color-text: #f1f5f9; /* slate-100 */
--color-border: #334155; /* slate-700 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
@@ -49,6 +52,49 @@
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f2d36; /* custom rose border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Light amber theme ────────────────────────────────────────────────── */
[data-theme="light"] {
--color-brand: #d97706; /* amber-600 */
--color-brand-dim: #b45309; /* amber-700 */
--color-surface: #ffffff;
--color-surface-2: #f4f4f5; /* zinc-100 */
--color-surface-3: #e4e4e7; /* zinc-200 */
--color-muted: #71717a; /* zinc-500 */
--color-text: #18181b; /* zinc-900 */
--color-border: #d4d4d8; /* zinc-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light slate theme ────────────────────────────────────────────────── */
[data-theme="light-slate"] {
--color-brand: #4f46e5; /* indigo-600 */
--color-brand-dim: #4338ca; /* indigo-700 */
--color-surface: #f8fafc; /* slate-50 */
--color-surface-2: #f1f5f9; /* slate-100 */
--color-surface-3: #e2e8f0; /* slate-200 */
--color-muted: #64748b; /* slate-500 */
--color-text: #0f172a; /* slate-900 */
--color-border: #cbd5e1; /* slate-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light rose theme ─────────────────────────────────────────────────── */
[data-theme="light-rose"] {
--color-brand: #e11d48; /* rose-600 */
--color-brand-dim: #be123c; /* rose-700 */
--color-surface: #fff1f2; /* rose-50 */
--color-surface-2: #ffe4e6; /* rose-100 */
--color-surface-3: #fecdd3; /* rose-200 */
--color-muted: #9f1239; /* rose-800 at 60% */
--color-text: #0f0a0b; /* near black */
--color-border: #fda4af; /* rose-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
html {
@@ -56,11 +102,18 @@ html {
color: var(--color-text);
}
/* ── Reading typography custom properties ──────────────────────────── */
:root {
--reading-font: system-ui, -apple-system, sans-serif;
--reading-size: 1.05rem;
}
/* ── Chapter prose ─────────────────────────────────────────────────── */
.prose-chapter {
max-width: 72ch;
line-height: 1.85;
font-size: 1.05rem;
font-family: var(--reading-font);
font-size: var(--reading-size);
color: var(--color-muted);
}

2
ui/src/app.d.ts vendored
View File

@@ -6,9 +6,11 @@ declare global {
interface Locals {
sessionId: string;
user: { id: string; username: string; role: string; authSessionId: string } | null;
isPro: boolean;
}
interface PageData {
user?: { id: string; username: string; role: string; authSessionId: string } | null;
isPro?: boolean;
}
// interface PageState {}
// interface Platform {}

View File

@@ -6,7 +6,7 @@ import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
@@ -141,7 +141,7 @@ export function parseAuthToken(token: string): { id: string; username: string; r
// ─── Hook ─────────────────────────────────────────────────────────────────────
function getTextDirection(locale: string): string {
// All supported locales (en, ru, id, pt-BR, fr) are LTR
// All supported locales (en, ru, id, pt, fr) are LTR
return 'ltr';
}
@@ -210,6 +210,18 @@ const appHandle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
}
// ── isPro: read fresh from DB so role changes take effect without re-login ──
if (event.locals.user) {
try {
const dbUser = await getUserById(event.locals.user.id);
event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin';
} catch {
event.locals.isPro = false;
}
} else {
event.locals.isPro = false;
}
return resolve(event);
};

View File

@@ -67,6 +67,8 @@
chapters?: { number: number; title: string }[];
/** List of available voices from the backend. */
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
}
let {
@@ -77,7 +79,8 @@
cover = '',
nextChapter = null,
chapters = [],
voices = []
voices = [],
onProRequired = undefined
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -563,6 +566,15 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (res.status === 402) {
// Free daily limit reached — surface upgrade CTA
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
return;
}
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status === 200) {

View File

@@ -55,6 +55,9 @@ export interface PBUserSettings {
voice: string;
speed: number;
theme?: string;
locale?: string;
font_family?: string;
font_size?: number;
updated?: string;
}
@@ -71,6 +74,8 @@ export interface User {
verification_token_exp?: string;
oauth_provider?: string;
oauth_id?: string;
polar_customer_id?: string;
polar_subscription_id?: string;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -192,8 +197,8 @@ async function countCollection(collection: string, filter = ''): Promise<number>
return (data as { totalItems: number }).totalItems ?? 0;
}
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
const params = new URLSearchParams({ perPage: '1', filter });
async function listOne<T>(collection: string, filter: string, sort = '-updated'): Promise<T | null> {
const params = new URLSearchParams({ perPage: '1', filter, sort });
const data = await pbGet<PBList<T>>(
`/api/collections/${collection}/records?${params.toString()}`
);
@@ -572,6 +577,28 @@ export async function getUserByOAuth(provider: string, oauthId: string): Promise
);
}
/**
* Look up a user by their Polar customer ID. Returns null if not found.
*/
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
return listOne<User>(
'app_users',
`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
);
}
/**
* Patch arbitrary fields on an app_user record.
*/
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
throw new Error(`patchUser failed: ${res.status}${body}`);
}
}
/**
* Create a new user via OAuth (no password). email_verified is true since the
* provider already verified it. Throws on DB errors.
@@ -779,7 +806,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -795,6 +822,9 @@ export async function saveSettings(
updated: new Date().toISOString()
};
if (settings.theme !== undefined) payload.theme = settings.theme;
if (settings.locale !== undefined) payload.locale = settings.locale;
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (userId) payload.user_id = userId;
if (existing) {
@@ -982,6 +1012,8 @@ export async function createUserSession(
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
pruneStaleUserSessions(userId).catch(() => {});
return rec.id;
}
@@ -1018,6 +1050,28 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
}
/**
* Delete sessions for a user that haven't been seen in the last `days` days.
* Called on login so the list self-cleans without a separate cron job.
*/
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const stale = await listAll<UserSession>(
'user_sessions',
`user_id="${userId}" && last_seen<"${cutoff}"`
);
if (stale.length === 0) return;
const token = await getToken();
await Promise.all(
stale.map((s) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
)
);
}
/**
* Revoke (delete) a specific session by its PocketBase record ID.
* Only allows deletion if the session belongs to the given userId.

107
ui/src/lib/server/polar.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Polar.sh integration — server-side only.
*
* Responsibilities:
* - Verify webhook signatures (HMAC-SHA256)
* - Patch app_users.polar_customer_id / polar_subscription_id / role on subscription events
* - Expose isPro(userId) helper for gating
*
* Product IDs (Polar dashboard):
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
export const POLAR_PRO_PRODUCT_IDS = new Set([
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
'b6190307-79aa-4905-80c8-9ed941378d21' // annual
]);
// ─── Webhook signature verification ──────────────────────────────────────────
/**
* Verify the Polar webhook signature.
* Polar signs with HMAC-SHA256 over the raw body; header is "webhook-signature".
* Header format: "v1=<hex>" (may be comma-separated list of sigs)
*/
export function verifyPolarWebhook(rawBody: string, signatureHeader: string): boolean {
const secret = env.POLAR_WEBHOOK_SECRET;
if (!secret) {
log.warn('polar', 'POLAR_WEBHOOK_SECRET not set — rejecting webhook');
return false;
}
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const expectedBuf = Buffer.from(`v1=${expected}`);
// Header may contain multiple sigs separated by ", "
const sigs = signatureHeader.split(',').map((s) => s.trim());
for (const sig of sigs) {
try {
const sigBuf = Buffer.from(sig);
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
return true;
}
} catch {
// length mismatch etc — try next
}
}
return false;
}
// ─── Subscription event handler ───────────────────────────────────────────────
interface PolarSubscription {
id: string;
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
product_id: string;
customer_id: string;
customer_email?: string;
user_id?: string; // Polar user id (not our user id)
}
/**
* Handle a Polar subscription event.
* Finds the matching app_user by email and updates role + polar fields.
*/
export async function handleSubscriptionEvent(
eventType: string,
subscription: PolarSubscription
): Promise<void> {
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
if (!customer_email) {
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
return;
}
// Find user by their polar_customer_id first (faster on repeat events), then by email
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (!user) {
const { getUserByEmail } = await import('$lib/server/pocketbase');
user = await getUserByEmail(customer_email).catch(() => null);
}
if (!user) {
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
return;
}
const isProProduct = POLAR_PRO_PRODUCT_IDS.has(product_id);
const isActive = status === 'active';
const newRole = isProProduct && isActive ? 'pro' : (user.role === 'admin' ? 'admin' : 'user');
await patchUser(user.id, {
role: newRole,
polar_customer_id: customer_id,
polar_subscription_id: isActive ? subId : ''
});
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
}

View File

@@ -4,16 +4,20 @@ import { getSettings } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']);
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms', '/catalogue']);
export const load: LayoutServerLoad = async ({ locals, url }) => {
// Allow /auth/* (OAuth initiation + callbacks) without login
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
// Allow public routes, /auth/*, and all book-browsing URLs (/books/[slug] and deeper)
// Note: /books (the personal library) is intentionally NOT public
const isPublic =
PUBLIC_ROUTES.has(url.pathname) ||
url.pathname.startsWith('/auth/') ||
url.pathname.startsWith('/books/');
if (!isPublic && !locals.user) {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber' };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
@@ -21,15 +25,39 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
autoNext: row.auto_next ?? false,
voice: row.voice ?? 'af_bella',
speed: row.speed ?? 1.0,
theme: row.theme ?? 'amber'
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size ?? 1.0
};
}
} catch (e) {
log.warn('layout', 'failed to load settings', { err: String(e) });
}
// If user is logged in, keep the PARAGLIDE_LOCALE cookie in sync with
// the saved locale so it persists across page loads and navigations.
if (locals.user) {
const savedLocale = settings.locale ?? 'en';
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
if (savedLocale === 'en') {
// Clear the cookie when the user's locale is English (the default)
if (currentCookieLocale) {
cookies.delete('PARAGLIDE_LOCALE', { path: '/' });
}
} else if (currentCookieLocale !== savedLocale) {
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
path: '/',
maxAge: 34560000,
sameSite: 'lax',
httpOnly: false
});
}
}
return {
user: locals.user,
isPro: locals.isPro,
settings
};
};

View File

@@ -10,13 +10,26 @@
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
// Mobile nav drawer state
let menuOpen = $state(false);
// Desktop dropdown menus
let userMenuOpen = $state(false);
let langMenuOpen = $state(false);
const THEMES = [
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
{ id: 'light', color: '#d97706', light: true },
{ id: 'light-slate', color: '#4f46e5', light: true },
{ id: 'light-rose', color: '#e11d48', light: true },
];
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
@@ -26,11 +39,17 @@
// ── Theme ──────────────────────────────────────────────────────────────
let currentTheme = $state(data.settings?.theme ?? 'amber');
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
// Expose theme state to child pages (e.g. profile theme picker)
// Expose theme + font state to child pages (e.g. profile picker)
setContext('theme', {
get current() { return currentTheme; },
set current(v: string) { currentTheme = v; }
set current(v: string) { currentTheme = v; },
get fontFamily() { return currentFontFamily; },
set fontFamily(v: string) { currentFontFamily = v; },
get fontSize() { return currentFontSize; },
set fontSize(v: number) { currentFontSize = v; }
});
$effect(() => {
@@ -39,6 +58,17 @@
}
});
$effect(() => {
if (typeof document === 'undefined') return;
const fontMap: Record<string, string> = {
system: 'system-ui, -apple-system, sans-serif',
serif: "Georgia, 'Times New Roman', serif",
mono: "'Courier New', monospace",
};
document.documentElement.style.setProperty('--reading-font', fontMap[currentFontFamily] ?? fontMap.system);
document.documentElement.style.setProperty('--reading-size', `${currentFontSize}rem`);
});
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
@@ -50,19 +80,23 @@
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
// Always sync theme (profile page calls invalidateAll after saving)
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
}
});
// ── Persist settings changes (debounced 800ms) ──────────────────────────
let settingsSaveTimer = 0;
$effect(() => {
// Subscribe to the four settings fields
// Subscribe to settings fields
const autoNext = audioStore.autoNext;
const voice = audioStore.voice;
const speed = audioStore.speed;
const theme = currentTheme;
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
@@ -72,7 +106,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -168,7 +202,7 @@
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
@@ -282,27 +316,104 @@
{m.nav_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') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_admin()}
</a>
{/if}
<a
href="/profile"
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-(--color-muted) hover:text-(--color-text)">
{m.nav_sign_out()}
</Button>
</form>
<div class="ml-auto flex items-center gap-2">
<!-- Theme dots (desktop) -->
<div class="hidden sm:flex items-center gap-1 mr-1">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-3 bg-(--color-border) mx-0.5"></span>
{/if}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
</div>
<!-- Language dropdown (desktop) -->
<div class="hidden sm:block relative">
<button
type="button"
onclick={() => { langMenuOpen = !langMenuOpen; userMenuOpen = false; }}
class="flex items-center gap-1 px-2 py-1 rounded text-xs font-mono transition-colors {langMenuOpen ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
{getLocale().toUpperCase()}
<svg class="w-3 h-3 shrink-0 transition-transform {langMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if langMenuOpen}
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
{#each locales as locale}
<button
type="button"
onclick={async () => {
langMenuOpen = false;
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="w-full text-left px-3 py-1.5 text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{locale.toUpperCase()}
</button>
{/each}
</div>
{/if}
</div>
<!-- User menu dropdown (desktop) -->
<div class="hidden sm:block relative">
<button
type="button"
onclick={() => { userMenuOpen = !userMenuOpen; langMenuOpen = false; }}
class="flex items-center gap-1.5 pl-1.5 pr-2 py-1 rounded transition-colors {userMenuOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'}"
>
<span class="w-6 h-6 rounded-full bg-(--color-brand)/20 text-(--color-brand) text-xs font-bold flex items-center justify-center shrink-0">
{data.user.username[0].toUpperCase()}
</span>
<svg class="w-3 h-3 text-(--color-muted) transition-transform {userMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if userMenuOpen}
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[170px]">
<a
href="/profile"
onclick={() => { userMenuOpen = false; }}
class="flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{m.nav_profile()}
<span class="text-xs opacity-40 truncate max-w-[80px]">{data.user.username}</span>
</a>
{#if data.user?.role === 'admin'}
<a
href="/admin/scrape"
onclick={() => { userMenuOpen = false; }}
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{m.nav_admin_panel()}
</a>
{/if}
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
{m.nav_sign_out()}
</button>
</form>
</div>
{/if}
</div>
<!-- Mobile: hamburger button -->
<Button
@@ -314,18 +425,25 @@
class="sm:hidden -mr-1"
>
{#if menuOpen}
<!-- X icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<!-- Hamburger icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</Button>
</div>
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else}
<div class="ml-auto">
<a
@@ -382,6 +500,51 @@
{m.nav_admin_panel()}
</a>
{/if}
<!-- Theme switcher -->
<div class="my-1 border-t border-(--color-border)/60"></div>
<div class="px-3 py-2.5 flex items-center justify-between">
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
<div class="flex items-center gap-1.5">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-4 bg-(--color-border) mx-0.5"></span>
{/if}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
</div>
</div>
<!-- Language switcher -->
<div class="px-3 py-2.5 flex items-center justify-between">
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.locale_switcher_label()}</span>
<div class="flex items-center gap-0.5">
{#each locales as locale}
<button
type="button"
onclick={async () => {
menuOpen = false;
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{locale.toUpperCase()}
</button>
{/each}
</div>
</div>
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<Button
@@ -432,22 +595,6 @@
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Locale switcher (always visible) -->
<div class="hidden sm:flex items-center gap-1 ml-2">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
data-sveltekit-reload
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text)'}"
aria-label="{m.locale_switcher_label()}: {locale}"
>
{locale.toUpperCase()}
</a>
{/each}
</div>
</nav>
<!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
@@ -540,6 +687,7 @@
<div class="px-0">
<input
type="range"
aria-label={m.player_seek_label()}
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}

View File

@@ -10,194 +10,220 @@
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
} catch { return []; }
}
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
// counting how many times the same book appears (= new chapters added).
const dedupedRecent = $derived.by(() => {
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
for (const book of data.recentlyUpdated) {
if (seen.has(book.slug)) {
seen.get(book.slug)!.count++;
} else {
seen.set(book.slug, { book, count: 1 });
}
}
return [...seen.values()];
});
const GENRES = [
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
];
// Hero = first continue-reading item; shelf = the rest
const heroBook = $derived(data.continueReading[0] ?? null);
const shelfBooks = $derived(data.continueReading.slice(1));
</script>
<svelte:head>
<title>{m.home_title()}</title>
</svelte:head>
<!-- Stats bar -->
<div class="flex gap-6 mb-8 text-center">
<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">{m.home_stat_books()}</p>
</div>
<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">{m.home_stat_chapters()}</p>
</div>
<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">{m.home_stat_in_progress()}</p>
</div>
</div>
<!-- Continue Reading -->
{#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-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
{#if heroBook}
<section class="mb-10">
<a
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
>
<!-- Cover -->
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
{#if heroBook.book.cover}
<img src={heroBook.book.cover} alt={heroBook.book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
{:else}
<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="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}
</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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<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" />
</svg>
</div>
{/if}
<!-- Chapter badge overlay -->
<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">
{m.home_chapter_badge({ n: String(chapter) })}
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.description}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.description}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</span>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
</div>
</div>
</a>
</section>
{/if}
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each shelfBooks as { book, chapter }}
<a href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
<a href="/catalogue?genre={encodeURIComponent(genre)}"
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
{genre}
</a>
{/each}
</div>
</section>
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each dedupedRecent as { book, count }}
{@const genres = parseGenres(book.genres)}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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 count > 1}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-success)/90 text-black font-bold px-1.5 py-0.5 rounded">
+{count} ch.
</span>
</div>
<div class="p-2">
<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-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Recently Updated -->
{#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-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<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" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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-(--color-muted) truncate">{book.author}</p>
{/if}
{#if book.status}
<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-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">{m.home_empty_body()}</p>
<a
href="/catalogue"
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
>
{m.home_discover_novels()}
</a>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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-(--color-muted) truncate">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- From Subscriptions -->
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#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-(--color-text)">{m.home_from_following()}</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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<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" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<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-(--color-muted) truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<p class="text-xs text-(--color-muted) truncate mt-0.5">
{m.home_via_reader({ username: readerUsername })}
</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-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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}
</div>
<div class="p-2 flex flex-col gap-0.5">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<p class="text-xs text-(--color-muted) truncate">{m.home_via_reader({ username: readerUsername })}</p>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">{m.home_empty_body()}</p>
<a href="/catalogue" class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg hover:bg-(--color-brand-dim) transition-colors">
{m.home_discover_novels()}
</a>
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
</div>

View File

@@ -2,6 +2,34 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import * as cache from '$lib/server/cache';
const FREE_DAILY_AUDIO_LIMIT = 3;
/**
* Return the number of audio chapters a user/session has generated today,
* and increment the counter. Uses a Valkey key that expires at midnight UTC.
*
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
*/
async function incrementDailyAudioCount(identifier: string): Promise<number> {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const key = `audio:daily:${identifier}:${today}`;
// Seconds until end of day UTC
const now = new Date();
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
// Use raw get/set with increment so we can read + increment atomically
try {
const raw = await cache.get<number>(key);
const current = (raw ?? 0) + 1;
await cache.set(key, current, ttl);
return current;
} catch {
// On cache failure, fail open (don't block audio for cache errors)
return 0;
}
}
/**
* POST /api/audio/[slug]/[n]
@@ -15,14 +43,39 @@ import { backendFetch } from '$lib/server/scraper';
* GET /api/presign/audio to obtain a direct MinIO presigned URL.
* 202 { task_id: string, status: "pending"|"generating" } — generation
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
* 402 { error: "pro_required", limit: 3 } — free daily limit reached.
*/
export const POST: RequestHandler = async ({ params, request }) => {
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
// ── Paywall: 3 audio chapters/day for free users ───────────────────────────
if (!locals.isPro) {
// Check if audio already exists (cached) before counting — no charge for
// re-requesting something already generated
const statusRes = await backendFetch(
`/api/audio/status/${slug}/${chapter}`
).catch(() => null);
const statusData = statusRes?.ok
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
: {};
if (statusData.status !== 'done') {
const identifier = locals.user?.id ?? locals.sessionId;
const count = await incrementDailyAudioCount(identifier);
if (count > FREE_DAILY_AUDIO_LIMIT) {
log.info('polar', 'free audio limit reached', { identifier, count });
return new Response(
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
let body: { voice?: string } = {};
try {
body = await request.json();
@@ -62,4 +115,3 @@ export const POST: RequestHandler = async ({ params, request }) => {
{ headers: { 'Content-Type': 'application/json' } }
);
};

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed, theme).
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -15,7 +15,10 @@ export const GET: RequestHandler = async ({ locals }) => {
autoNext: settings?.auto_next ?? false,
voice: settings?.voice ?? 'af_bella',
speed: settings?.speed ?? 1.0,
theme: settings?.theme ?? 'amber'
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size ?? 1.0
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -25,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string }
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -41,11 +44,29 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}
// theme is optional — if provided it must be a known value
const validThemes = ['amber', 'slate', 'rose'];
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {

View File

@@ -0,0 +1,69 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
/**
* POST /api/translation/[slug]/[n]?lang=<lang>
* Proxy to backend translation enqueue endpoint.
* Enforces Pro gate — free users cannot enqueue translations.
*
* GET /api/translation/[slug]/[n]?lang=<lang>
* Proxy to backend translation fetch (no gate — already gated at page.server.ts).
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const lang = url.searchParams.get('lang') ?? '';
const res = await backendFetch(
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (!res.ok) {
return new Response(null, { status: res.status });
}
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};
export const POST: RequestHandler = async ({ params, url, locals }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
const lang = url.searchParams.get('lang') ?? '';
if (!slug || !chapter || chapter < 1) error(400, 'Invalid slug or chapter');
if (!SUPPORTED_LANGS.has(lang)) error(400, 'Unsupported language');
// ── Pro gate ──────────────────────────────────────────────────────────────
if (!locals.isPro) {
log.info('polar', 'translation blocked for free user', {
userId: locals.user?.id,
slug,
chapter,
lang
});
return new Response(
JSON.stringify({ error: 'pro_required' }),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}
const res = await backendFetch(
`/api/translation/${encodeURIComponent(slug)}/${chapter}?lang=${lang}`,
{ method: 'POST' }
);
if (!res.ok) {
const text = await res.text().catch(() => '');
log.error('translation', 'backend translation enqueue failed', { slug, chapter, lang, status: res.status, body: text });
error(res.status as Parameters<typeof error>[0], text || 'Translation enqueue failed');
}
const data = await res.json();
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,23 @@
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/translation/status/[slug]/[n]?lang=<lang>
* Proxies the translation status check to the backend.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const lang = url.searchParams.get('lang') ?? '';
const res = await backendFetch(
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (!res.ok) {
return new Response(JSON.stringify({ status: 'idle' }), {
headers: { 'Content-Type': 'application/json' }
});
}
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,52 @@
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
/**
* POST /api/webhooks/polar
*
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
* Signature is verified via HMAC-SHA256 before any processing.
*/
export const POST: RequestHandler = async ({ request }) => {
const rawBody = await request.text();
const signature = request.headers.get('webhook-signature') ?? '';
if (!verifyPolarWebhook(rawBody, signature)) {
log.warn('polar', 'webhook signature verification failed');
return new Response('Unauthorized', { status: 401 });
}
let event: { type: string; data: Record<string, unknown> };
try {
event = JSON.parse(rawBody);
} catch {
return new Response('Bad Request', { status: 400 });
}
const { type, data } = event;
log.info('polar', 'webhook received', { type });
try {
switch (type) {
case 'subscription.created':
case 'subscription.updated':
case 'subscription.revoked':
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
break;
case 'order.created':
// One-time purchases — no role change needed for now
log.info('polar', 'order.created (no action)', { orderId: data.id });
break;
default:
log.debug('polar', 'unhandled webhook event type', { type });
}
} catch (err) {
// Log but return 200 — Polar retries on non-2xx, we don't want retry storms
log.error('polar', 'webhook handler error', { type, err: String(err) });
}
return new Response('OK', { status: 200 });
};

View File

@@ -25,7 +25,7 @@ import {
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
@@ -227,12 +227,21 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
);
// ── Create session + auth cookie ──────────────────────────────────────────
const authSessionId = randomBytes(16).toString('hex');
const userAgent = '' ; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
let authSessionId: string;
// Reuse existing session if the user is already logged in as the same user
if (locals.user?.id === user.id && locals.user?.authSessionId) {
authSessionId = locals.user.authSessionId;
// Just touch the existing session to update last_seen
touchUserSession(authSessionId).catch(() => {});
} else {
authSessionId = randomBytes(16).toString('hex');
const userAgent = ''; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {

View File

@@ -141,7 +141,7 @@
<p class="text-(--color-muted) text-sm mt-1">
{m.book_detail_scraping_progress()}
</p>
{#if data.taskId}
{#if data.taskId && data.user?.role === 'admin'}
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
{/if}
</div>
@@ -243,7 +243,17 @@
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if data.inLib}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) 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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
@@ -294,7 +304,17 @@
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if data.inLib}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) 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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}

View File

@@ -17,8 +17,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const isPreview = url.searchParams.get('preview') === '1';
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
const chapterTitle = url.searchParams.get('title') ?? '';
const lang = url.searchParams.get('lang') ?? '';
const useTranslation = SUPPORTED_LANGS.has(lang);
const rawLang = url.searchParams.get('lang') ?? '';
// Non-pro users can only read EN — silently ignore lang param
const lang = locals.isPro && SUPPORTED_LANGS.has(rawLang) ? rawLang : '';
const useTranslation = lang !== '';
if (isPreview) {
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
@@ -83,7 +85,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
sessionId: locals.sessionId,
isPreview: true,
lang: '',
translationStatus: 'unavailable' as string
translationStatus: 'unavailable' as string,
isPro: locals.isPro
};
}
@@ -129,10 +132,11 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false,
lang,
translationStatus: 'done'
sessionId: locals.sessionId,
isPreview: false,
lang,
translationStatus: 'done',
isPro: locals.isPro
};
}
// 404 = not generated yet — fall through to original, UI can trigger generation
@@ -188,6 +192,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
sessionId: locals.sessionId,
isPreview: false,
lang: useTranslation ? lang : '',
translationStatus
translationStatus,
isPro: locals.isPro
};
};

View File

@@ -11,6 +11,7 @@
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let fetchError = $state('');
let audioProRequired = $state(false);
// Translation state
const SUPPORTED_LANGS = [
@@ -35,6 +36,10 @@
}
async function requestTranslation(lang: string) {
if (!data.isPro) {
// Don't even attempt — show upgrade inline
return;
}
translatingLang = lang;
translationStatus = 'pending';
try {
@@ -42,6 +47,11 @@
`/api/translation/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`,
{ method: 'POST' }
);
if (res.status === 402) {
translationStatus = 'idle';
translatingLang = '';
return;
}
const d = (await res.json()) as { status: string };
translationStatus = d.status ?? 'pending';
if (d.status === 'done') {
@@ -152,7 +162,7 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_chapter_n({ n: String(data.prev) })}
</a>
@@ -160,7 +170,7 @@
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_chapter_n({ n: String(data.next) })} &rarr;
</a>
@@ -174,7 +184,11 @@
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
</h1>
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</p>
<p class="text-(--color-muted) text-xs mt-1">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-50 mx-1">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min read
</p>
{/if}
</div>
@@ -192,7 +206,19 @@
</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
{#if !data.isPro}
<!-- Locked for free users -->
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<!-- Spinning indicator while translating -->
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -216,11 +242,43 @@
>{label}</button>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
{/if}
</div>
{/if}
<!-- Audio player -->
{#if !data.isPreview}
{#if !page.data.user}
<!-- Unauthenticated: sign-in prompt -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
</div>
<a
href="/login"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
{m.nav_sign_in()}
</a>
</div>
{:else if audioProRequired}
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
</div>
<a
href="/profile"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Upgrade
</a>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
@@ -230,7 +288,9 @@
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
{:else}
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
@@ -261,17 +321,17 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_prev_chapter()}
</a>
{:else}
<div></div>
<span></span>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_next_chapter()} &rarr;
</a>

View File

@@ -117,11 +117,15 @@
{ value: 'rank', label: m.catalogue_sort_rank() }
]);
const FALLBACK_STATUSES = ['ongoing', 'completed'];
const STATUS_LABELS: Record<string, () => string> = {
ongoing: () => m.catalogue_status_ongoing(),
completed: () => m.catalogue_status_completed(),
};
const statuses = $derived([
{ value: 'all', label: m.catalogue_status_all() },
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1)
label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1))
})))
]);
@@ -238,11 +242,11 @@
<!-- Admin flash messages -->
{#if form}
{#if form.status === 'queued'}
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
<div class="mb-4 px-4 py-3 rounded bg-(--color-success)/10 border border-(--color-success)/40 text-(--color-success) text-sm">
{m.catalogue_scrape_queued_flash()}
</div>
{:else if form.status === 'busy'}
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
<div class="mb-4 px-4 py-3 rounded bg-(--color-brand)/10 border border-(--color-brand)/40 text-(--color-brand) text-sm">
{m.catalogue_scrape_busy_flash()}
</div>
{:else if form.status === 'error'}

View File

@@ -34,7 +34,7 @@
href="/auth/google"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
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"
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
>
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path
@@ -62,7 +62,7 @@
href="/auth/github"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
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"
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
>
<svg class="w-5 h-5 shrink-0 fill-(--color-text)" viewBox="0 0 24 24" aria-hidden="true">
<path

View File

@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -38,40 +38,3 @@ export const load: PageServerLoad = async ({ locals }) => {
}))
};
};
export const actions: Actions = {
changePassword: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not logged in.' });
}
const data = await request.formData();
const current = (data.get('current') as string | null) ?? '';
const next = (data.get('next') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!current || !next || !confirm) {
return fail(400, { error: 'All fields are required.' });
}
if (next.length < 8) {
return fail(400, { error: 'New password must be at least 8 characters.' });
}
if (next !== confirm) {
return fail(400, { error: 'New passwords do not match.' });
}
let ok: boolean;
try {
ok = await changePassword(locals.user.id, current, next);
} catch (e) {
log.error('profile', 'changePassword failed', { err: String(e) });
return fail(500, { error: 'An error occurred. Please try again.' });
}
if (!ok) {
return fail(401, { error: 'Current password is incorrect.' });
}
return { success: true };
}
};

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -16,14 +15,12 @@
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
@@ -33,7 +30,6 @@
avatarUploading = true;
avatarError = '';
try {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': mimeType },
@@ -57,87 +53,104 @@
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
// Derived: voices grouped by engine
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// Mirror from audioStore so sliders feel live
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
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 settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
const FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
async function saveSettings() {
settingsSaving = true;
settingsSaved = false;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
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);
} finally {
settingsSaving = false;
}
}
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
// ── Password change ─────────────────────────────────────────────────────────
let pwSubmitting = $state(false);
let pwSuccess = $state(false);
// ── Auto-save ────────────────────────────────────────────────────────────────
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
$effect(() => {
if (form?.success) {
pwSuccess = true;
setTimeout(() => (pwSuccess = false), 3000);
// Read all settings deps to subscribe
const t = selectedTheme;
const ff = selectedFontFamily;
const fs = selectedFontSize;
const v = voice;
const sp = speed;
const an = autoNext;
// Apply context immediately (font/theme previews live without waiting for save)
if (settingsCtx) {
settingsCtx.current = t;
settingsCtx.fontFamily = ff;
settingsCtx.fontSize = fs;
}
audioStore.voice = v;
audioStore.autoNext = an;
if (!initialized) { initialized = true; return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
saveStatus = 'saving';
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
});
saveStatus = 'saved';
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
} catch {
saveStatus = 'idle';
}
}, 800) as unknown as number;
});
// ── Sessions ────────────────────────────────────────────────────────────────
// ── Sessions ────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
@@ -156,19 +169,12 @@
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) {
revokeError = 'Failed to end session. Please try again.';
return;
}
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) {
logoutForm.submit();
}
if (logoutForm) logoutForm.submit();
return;
}
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
@@ -180,18 +186,12 @@
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
} catch { return iso; }
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
@@ -210,24 +210,20 @@
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div class="flex items-center gap-5">
<!-- Avatar -->
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
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)"
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"
title={m.profile_change_avatar()}
disabled={avatarUploading}
>
@@ -240,7 +236,6 @@
</svg>
</div>
{/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
@@ -255,34 +250,96 @@
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
</div>
<div>
<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>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_plan_pro()}
</span>
{/if}
</div>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</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)">{m.profile_appearance_heading()}</h2>
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</div>
</div>
</section>
{:else}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</section>
{/if}
<div class="space-y-2">
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<!-- Section header with auto-save indicator -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
{#if saveStatus === 'saving'}
{m.profile_saving()}
{:else if saveStatus === 'saved'}
{m.profile_saved()}
{:else}
{m.profile_saved()}
{/if}
</span>
</div>
<!-- Theme -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-3 flex-wrap">
{#each THEMES as t}
<div class="flex gap-2 flex-wrap items-center">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
{/if}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
@@ -292,111 +349,120 @@
: '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>
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" 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-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
<!-- Font family -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONTS as f}
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontFamily === f.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={selectedFontFamily === f.id}
>
{f.label()}
</button>
{/each}
</div>
</div>
<!-- Voice -->
<div class="space-y-1.5">
<!-- Text size -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s}
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontSize === s.value
? '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={selectedFontSize === s.value}
>
{s.label()}
</button>
{/each}
</div>
</div>
<!-- TTS voice -->
<div class="px-6 py-5 space-y-3">
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
{#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{:else if voices.length === 0}
<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>{m.common_loading()}</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
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)"
>
<select id="voice-select" bind:value={voice}
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)">
{#each kokoroVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
</select>
{/if}
</div>
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
</label>
<input
id="speed-range"
type="range"
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
style="accent-color: var(--color-brand);"
class="w-full"
/>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
</div>
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
style="accent-color: var(--color-brand);" class="w-full" />
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span>
<span>3.0x</span>
<span>0.5x</span><span>3.0x</span>
</div>
</div>
<!-- Auto-next -->
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={autoNext}
style="accent-color: var(--color-brand);"
class="w-4 h-4 rounded"
/>
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
</label>
<div class="flex items-center gap-3 pt-1">
<!-- Auto-advance -->
<div class="px-6 py-5 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
</div>
<button
onclick={saveSettings}
disabled={settingsSaving}
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"
type="button"
role="switch"
aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)}
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
<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)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
<div>
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
</div>
{#if revokeError}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{revokeError}
</div>
<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}
@@ -428,7 +494,7 @@
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? '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)'}"
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
>
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button>
@@ -438,74 +504,4 @@
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<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)">{m.profile_change_password_heading()}</h2>
{#if form?.error}
<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}
{#if pwSuccess}
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
{m.profile_password_changed_ok()}
</div>
{/if}
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
pwSubmitting = true;
return async ({ update }) => {
pwSubmitting = false;
await update();
};
}}
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
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-(--color-text)" for="next">{m.profile_new_password()}</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
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-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
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-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
>
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
</button>
</form>
</section>
</div>

View File

@@ -0,0 +1,37 @@
import type { RequestHandler } from './$types';
const SITE = 'https://libnovel.cc';
// Only static public pages are listed here. Book/catalogue pages are
// discoverable via the catalogue link and don't need individual entries
// (the catalogue itself serves as an index for crawlers).
const PUBLIC_PAGES = [
{ path: '/catalogue', changefreq: 'daily', priority: '0.9' },
{ path: '/login', changefreq: 'monthly', priority: '0.5' },
{ path: '/disclaimer', changefreq: 'yearly', priority: '0.2' },
{ path: '/privacy', changefreq: 'yearly', priority: '0.2' },
{ path: '/dmca', changefreq: 'yearly', priority: '0.2' },
];
export const GET: RequestHandler = () => {
const urls = PUBLIC_PAGES.map(
({ path, changefreq, priority }) => `
<url>
<loc>${SITE}${path}</loc>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
</url>`
).join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -1,3 +1,14 @@
# allow crawling everything by default
User-agent: *
Disallow:
Disallow: /books
Disallow: /profile
Disallow: /admin/
Disallow: /api/
Disallow: /auth/
Allow: /books/
Allow: /catalogue
Allow: /login
Allow: /disclaimer
Allow: /privacy
Allow: /dmca
Sitemap: https://libnovel.cc/sitemap.xml