Compare commits

...

11 Commits

Author SHA1 Message Date
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
26 changed files with 1027 additions and 434 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

@@ -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

@@ -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",

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",

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",

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",

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": "Следующая глава",

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,7 @@
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f2d36; /* custom rose border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
html {

View File

@@ -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';
}

View File

@@ -197,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()}`
);
@@ -1012,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;
}
@@ -1048,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.

View File

@@ -4,11 +4,15 @@ 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, cookies }) => {
// Allow /auth/* (OAuth initiation + callbacks) without login
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
// 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`);
}
@@ -31,20 +35,23 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
log.warn('layout', 'failed to load settings', { err: String(e) });
}
// If user is logged in and has a non-English locale saved, ensure the
// PARAGLIDE_LOCALE cookie is set so the locale persists after refresh.
// 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';
if (savedLocale !== 'en') {
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
if (currentCookieLocale !== savedLocale) {
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
path: '/',
maxAge: 34560000,
sameSite: 'lax',
httpOnly: false
});
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
});
}
}

View File

@@ -17,6 +17,16 @@
// 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' },
];
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
@@ -303,61 +313,101 @@
{m.nav_feedback()}
</a>
<div class="ml-auto flex items-center gap-4">
<!-- Theme quick picker -->
<div class="hidden sm:flex items-center gap-1">
{#each [{ id: 'amber', color: '#f59e0b' }, { id: 'slate', color: '#818cf8' }, { id: 'rose', color: '#fb7185' }] as t}
<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}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-4 h-4 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-60 hover:opacity-100'}"
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
</div>
<!-- Language quick picker -->
<div class="hidden sm:flex items-center gap-0.5">
{#each locales as locale}
<button
type="button"
onclick={async () => {
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}
<!-- 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>
<!-- 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>
<!-- 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
@@ -369,18 +419,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
@@ -437,6 +494,48 @@
{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-2">
{#each THEMES as t}
<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' : '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
@@ -487,28 +586,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 (footer) -->
<div class="hidden sm:flex items-center gap-1 ml-2">
{#each locales as locale}
<button
type="button"
onclick={async () => {
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)'}"
aria-label="{m.locale_switcher_label()}: {locale}"
>
{locale.toUpperCase()}
</button>
{/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)">
@@ -601,6 +678,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

@@ -50,7 +50,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}
// locale is optional — if provided it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt-BR', 'fr'];
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(', ')}`);
}

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

@@ -162,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>
@@ -170,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>
@@ -184,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>
@@ -247,7 +251,21 @@
<!-- Audio player -->
{#if !data.isPreview}
{#if audioProRequired}
{#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>
@@ -303,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

@@ -242,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,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,95 +53,101 @@
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 + Font ─────────────────────────────────────────────────────────────
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' },
{ 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 FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ 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() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
// ── Auto-save ────────────────────────────────────────────────────────────────
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
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, fontFamily: selectedFontFamily, fontSize: selectedFontSize })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
// Apply theme + font live via context
if (settingsCtx) {
settingsCtx.current = selectedTheme;
settingsCtx.fontFamily = selectedFontFamily;
settingsCtx.fontSize = selectedFontSize;
}
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
$effect(() => {
// 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;
// ── Sessions ────────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
@@ -164,19 +166,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.';
@@ -188,18 +183,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';
@@ -218,24 +207,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}
>
@@ -248,7 +233,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">
@@ -263,96 +247,92 @@
{/if}
</div>
</button>
<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>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
{#if data.isPro}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-400/15 text-amber-400 border border-amber-400/30 tracking-wide uppercase">
<svg class="w-3.5 h-3.5" 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>
{:else}
<span class="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>
{/if}
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
</div>
{#if data.isPro}
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p>
<a
href="https://polar.sh/libnovel"
target="_blank"
rel="noopener noreferrer"
class="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>
{:else}
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
<div>
<p class="text-sm font-semibold text-(--color-text) mb-3">{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.5 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.5 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-amber-400/15 text-amber-400 border border-amber-400/30">33%</span>
</a>
</div>
<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}
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── 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}
<!-- ── 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>
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<div class="space-y-2">
<!-- 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">
<div class="flex gap-2 flex-wrap">
{#each THEMES as t}
<button
type="button"
@@ -363,26 +343,25 @@
: '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" 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>
<div class="space-y-2">
<!-- 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)'}"
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()}
@@ -391,14 +370,18 @@
</div>
</div>
<div class="space-y-2">
<!-- 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)'}"
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()}
@@ -407,115 +390,73 @@
</div>
</div>
<div class="flex items-center gap-3 pt-1">
<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"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">{m.profile_saved()}</span>
{/if}
</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>
<!-- Voice -->
<div class="space-y-1.5">
<!-- 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 toggle -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
<!-- 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
type="button"
role="switch"
aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)}
class="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)'}"
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)'}"
>
<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>
</div>
<div class="flex items-center gap-3 pt-1">
<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"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</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}
@@ -547,7 +488,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>
@@ -556,4 +497,5 @@
</ul>
{/if}
</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