Compare commits

...

8 Commits

Author SHA1 Message Date
root
c900fc476f fix: add forest, mono, cyber to settings API validThemes allowlist
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m40s
Release / Docker (push) Successful in 5m43s
Release / Gitea Release (push) Successful in 31s
2026-04-07 12:09:56 +05:00
root
d612b40fdb ci: consolidate Docker jobs into one + comment out sourcemaps step
- Merged docker-backend, docker-runner, docker-ui, docker-caddy into a
  single 'docker' job. Docker Hub is now authenticated once; the credential
  in ~/.docker/config.json is reused by all four build-push-action steps.
  Eliminates 3 redundant login, checkout, setup-buildx, and scheduler
  round-trips. Builds still run sequentially within the job so inline layer
  cache pushes don't race each other.

- docker-ui now downloads the plain ui-build artifact from check-ui directly
  (previously it depended on upload-sourcemaps which produced ui-build-injected).

- release job now only needs: [docker] instead of the previous 5-job list.

- Entire upload-sourcemaps job commented out as requested. Re-enable by
  uncommenting the job block and adding 'upload-sourcemaps' back to the
  docker job's needs list (also swap ui-build → ui-build-injected artifact).
2026-04-07 12:01:34 +05:00
root
faa4c42f20 fix: add missing Paraglide compiled message files for new themes + aria-label
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m39s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 2m53s
Release / Upload source maps (push) Successful in 1m27s
Release / Docker / ui (push) Successful in 2m53s
Release / Docker / caddy (push) Successful in 48s
Release / Gitea Release (push) Successful in 36s
CI svelte-check failed because Paraglide generates per-key .js files that
must be committed — the compiler runs at build time in CI (svelte-kit sync)
but the output directory is tracked in git, so new keys added to messages/*.json
require the corresponding generated files to be committed manually when node
is not available locally.

Added:
- messages/profile_theme_forest.js
- messages/profile_theme_mono.js
- messages/profile_theme_cyber.js
- messages/_index.js: three new export lines

Also fixed a11y warn: added aria-label='Close history' to the icon-only
close button in the discover page history drawer.
2026-04-07 11:40:38 +05:00
root
17fa913ba9 feat: add Forest, Mono, and Cyberpunk color themes
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 31s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m29s
Release / Docker / runner (push) Successful in 2m39s
Release / Gitea Release (push) Has been skipped
Forest — deep green surfaces (#0a130d base) with green-400 accent.
  Inspired by terminal emulators and dark mode IDE forest schemes.

Mono — near-black zinc-950 surface with pure white/zinc-100 accent.
  Minimal high-contrast monochromatic palette for distraction-free reading.

Cyber — near-black blue surface with neon cyan-400 accent and dracula-palette
  success/danger colors. Cyberpunk aesthetic with neon glow feel.

Changes:
- app.css: three new [data-theme] blocks with full token sets
- +layout.svelte: added forest/mono/cyber to THEMES array (before light themes)
- profile/+page.svelte: same additions with i18n label functions
- messages/*.json: translated names in all 5 locales (en/fr/ru/pt/id)
2026-04-07 11:27:30 +05:00
root
95f45a5f13 fix: chapter list modal full-screen clip + add clear close button
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 2m2s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 3m31s
Release / Docker / runner (push) Successful in 3m33s
Release / Upload source maps (push) Successful in 1m37s
Release / Docker / ui (push) Successful in 5m16s
Release / Gitea Release (push) Successful in 44s
Root cause: the chapter picker overlay (fixed inset-0) was rendered as a
descendant of the AudioPlayer's wrapping div, which is itself inside a
'rounded-b-lg overflow-hidden' container on the chapter page. Chrome clips
position:fixed descendants when a parent has overflow:hidden + border-radius,
so the overlay was constrained to the parent's bounding box instead of
covering the full viewport.

Fix: moved the {#if showChapterPanel} block from inside the standard player
div to a top-level sibling at the end of the component template. Svelte
components support multiple root nodes, so the overlay is now a sibling of
all player containers — no ancestor overflow-hidden can clip it.

Also replaced the subtle chevron-down close button with a clear X (×) button
in the top-right of the header, making it obvious how to dismiss the panel.
2026-04-07 11:00:58 +05:00
root
2ed37f78c7 fix: announce chapter reliability — timeout fallback + eager chapters sync
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 1m53s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m37s
Release / Docker / runner (push) Successful in 2m33s
Release / Upload source maps (push) Successful in 1m29s
Release / Docker / ui (push) Successful in 2m30s
Release / Gitea Release (push) Successful in 32s
Two issues causing announce to silently fail or permanently block navigation:

1. No hard timeout fallback on speechSynthesis.speak():
   Chrome Android (and some desktop) silently drops utterances not triggered
   within a user-gesture window. If both onend and onerror fail to fire (a
   known browser bug), doNavigate() was never called and the chapter
   transition was permanently lost. Added an 8-second setTimeout fallback
   (safeNavigate) that forces navigation if the speech engine never resolves.
   safeNavigate is idempotent — guarded by a 'navigated' flag so it only
   fires once even if onend, onerror, and the timeout all fire.

2. audioStore.chapters only written inside startPlayback():
   The onended handler reads audioStore.chapters to build the utterance text
   (Chapter N — Title). If auto-next navigated to this chapter and the user
   never manually pressed play (startPlayback was never called), chapters
   held whatever the previous AudioPlayer had written — potentially stale or
   empty on a book switch. Added a reactive $effect that keeps chapters in
   sync whenever the prop changes, same pattern as nextChapter.
2026-04-06 22:35:54 +05:00
root
963ecdd89b fix: auto-next transition deadlock and resume-at-end bug
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m38s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m38s
Release / Docker / runner (push) Successful in 2m38s
Release / Upload source maps (push) Successful in 1m23s
Release / Docker / ui (push) Successful in 2m26s
Release / Gitea Release (push) Successful in 31s
Bug 1 — Auto-next not transitioning:
audioExpanded defaulted to false on the new chapter page because
audioStore.chapter still held the old chapter number when the page script
initialized. The $effect only opened the panel when isPlaying was already
true — a circular dependency (can't play without the panel, panel only opens
when playing). Fix: also set audioExpanded=true when autoStartChapter targets
this chapter, both in the initial $state and in the reactive $effect.

Bug 2 — Resume starts at the end:
onended called saveAudioTime() which captured currentTime≈duration and fired a
PATCH 2 seconds later (after navigation had already completed). Next visit to
that chapter restored the end-of-file position. Fix: in onended, cancel the
debounced timer (clearTimeout) and immediately PATCH audioTime=0 for the
finished chapter, so it always resumes from the beginning on re-visit.
2026-04-06 21:51:47 +05:00
root
12963342bb fix: update votedBooks state immediately on swipe so history drawer isn't empty
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m35s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / runner (push) Successful in 2m42s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 29s
doAction() was fire-and-forgetting the POST but never updating the client-side
votedBooks array. History was only populated from SSR data.votedBooks (loaded
at page init), so any votes cast during the current session were invisible in
the drawer until a full page reload. Now we prepend/replace an entry in
votedBooks optimistically the moment a swipe action fires.
2026-04-06 21:45:29 +05:00
17 changed files with 459 additions and 239 deletions

View File

@@ -62,24 +62,115 @@ jobs:
path: ui/build
retention-days: 1
# ── docker: backend ───────────────────────────────────────────────────────────
docker-backend:
name: Docker / backend
# ── ui: source map upload ─────────────────────────────────────────────────────
# Commented out — re-enable when GlitchTip source map uploads are needed again.
#
# upload-sourcemaps:
# name: Upload source maps
# runs-on: ubuntu-latest
# needs: [check-ui]
# steps:
# - name: Compute release version (strip leading v)
# id: ver
# run: |
# V="${{ gitea.ref_name }}"
# echo "version=${V#v}" >> "$GITHUB_OUTPUT"
#
# - name: Download build artifacts
# uses: actions/download-artifact@v3
# with:
# name: ui-build
# path: build
#
# - name: Install sentry-cli
# run: npm install -g @sentry/cli
#
# - name: Inject debug IDs into build artifacts
# run: sentry-cli sourcemaps inject ./build
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Upload injected build (for docker-ui)
# uses: actions/upload-artifact@v3
# with:
# name: ui-build-injected
# path: build
# retention-days: 1
#
# - name: Create GlitchTip release
# run: sentry-cli releases new ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Upload source maps to GlitchTip
# run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Finalize GlitchTip release
# run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Prune old GlitchTip releases (keep latest 10)
# run: |
# set -euo pipefail
# KEEP=10
# OLD=$(curl -sf \
# -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
# "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
# | python3 -c "
# import sys, json
# releases = json.load(sys.stdin)
# for r in releases[$KEEP:]:
# print(r['version'])
# " KEEP=$KEEP)
# for ver in $OLD; do
# echo "Deleting old release: $ver"
# sentry-cli releases delete "$ver" || true
# done
# env:
# SENTRY_URL: https://errors.libnovel.cc
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
# ── docker: all images in one job (single login) ──────────────────────────────
# backend, runner, ui, and caddy are built sequentially in one job so Docker
# Hub only needs to be authenticated once. This also eliminates 3 redundant
# checkout + setup-buildx + scheduler round-trips compared to separate jobs.
docker:
name: Docker
runs-on: ubuntu-latest
needs: [test-backend]
needs: [test-backend, check-ui]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Single login — credential is written to ~/.docker/config.json and
# reused by all subsequent build-push-action steps in this job.
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
# ── backend ──────────────────────────────────────────────────────────────
- name: Docker meta / backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
@@ -88,38 +179,23 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
- name: Build and push / backend
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.meta-backend.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
cache-to: type=inline
# ── docker: runner ────────────────────────────────────────────────────────────
docker-runner:
name: Docker / runner
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
# ── runner ───────────────────────────────────────────────────────────────
- name: Docker meta / runner
id: meta-runner
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
@@ -128,115 +204,25 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
- name: Build and push / runner
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-runner.outputs.tags }}
labels: ${{ steps.meta-runner.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.meta-runner.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
cache-to: type=inline
# ── ui: source map upload ─────────────────────────────────────────────────────
upload-sourcemaps:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
steps:
- name: Compute release version (strip leading v)
id: ver
run: |
V="${{ gitea.ref_name }}"
echo "version=${V#v}" >> "$GITHUB_OUTPUT"
- name: Download build artifacts
# ── ui ───────────────────────────────────────────────────────────────────
- name: Download ui build artifacts
uses: actions/download-artifact@v3
with:
name: ui-build
path: build
- name: Install sentry-cli
run: npm install -g @sentry/cli
- name: Inject debug IDs into build artifacts
run: sentry-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Upload injected build (for docker-ui)
uses: actions/upload-artifact@v3
with:
name: ui-build-injected
path: build
retention-days: 1
- name: Create GlitchTip release
run: sentry-cli releases new ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Upload source maps to GlitchTip
run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Finalize GlitchTip release
run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Prune old GlitchTip releases (keep latest 10)
run: |
set -euo pipefail
KEEP=10
OLD=$(curl -sf \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
| python3 -c "
import sys, json
releases = json.load(sys.stdin)
for r in releases[$KEEP:]:
print(r['version'])
" KEEP=$KEEP)
for ver in $OLD; do
echo "Deleting old release: $ver"
sentry-cli releases delete "$ver" || true
done
env:
SENTRY_URL: https://errors.libnovel.cc
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
- name: Download injected build (debug IDs already embedded)
uses: actions/download-artifact@v3
with:
name: ui-build-injected
path: ui/build
- name: Allow build/ into Docker context (override .dockerignore)
@@ -244,16 +230,8 @@ jobs:
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
- name: Docker meta / ui
id: meta-ui
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
@@ -262,38 +240,24 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
- name: Build and push / ui
uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_VERSION=${{ steps.meta-ui.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
PREBUILT=1
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline
# ── docker: caddy ─────────────────────────────────────────────────────────────
docker-caddy:
name: Docker / caddy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
# ── caddy ────────────────────────────────────────────────────────────────
- name: Docker meta / caddy
id: meta-caddy
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-caddy
@@ -302,13 +266,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
- name: Build and push / caddy
uses: docker/build-push-action@v6
with:
context: caddy
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-caddy.outputs.tags }}
labels: ${{ steps.meta-caddy.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
cache-to: type=inline
@@ -316,7 +280,7 @@ jobs:
release:
name: Gitea Release
runs-on: ubuntu-latest
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
needs: [docker]
steps:
- uses: actions/checkout@v4
with:

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Slate",
"profile_theme_rose": "Rose",
"profile_theme_forest": "Forest",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Ambre",
"profile_theme_slate": "Ardoise",
"profile_theme_rose": "Rose",
"profile_theme_forest": "Forêt",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Abu-abu",
"profile_theme_rose": "Mawar",
"profile_theme_forest": "Hutan",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Âmbar",
"profile_theme_slate": "Ardósia",
"profile_theme_rose": "Rosa",
"profile_theme_forest": "Floresta",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Янтарь",
"profile_theme_slate": "Сланец",
"profile_theme_rose": "Роза",
"profile_theme_forest": "Лес",
"profile_theme_mono": "Моно",
"profile_theme_cyber": "Киберпанк",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -97,6 +97,48 @@
--color-success: #16a34a; /* green-600 */
}
/* ── Forest theme — dark green ────────────────────────────────────────── */
[data-theme="forest"] {
--color-brand: #4ade80; /* green-400 */
--color-brand-dim: #16a34a; /* green-600 */
--color-surface: #0a130d; /* custom near-black green */
--color-surface-2: #111c14; /* custom dark green */
--color-surface-3: #1a2e1e; /* custom mid green */
--color-muted: #6b9a77; /* custom muted green */
--color-text: #e8f5e9; /* custom light green-tinted white */
--color-border: #1e3a24; /* custom green border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Mono theme — pure dark with white accent ─────────────────────────── */
[data-theme="mono"] {
--color-brand: #f4f4f5; /* zinc-100 — white accent */
--color-brand-dim: #a1a1aa; /* zinc-400 */
--color-surface: #09090b; /* zinc-950 */
--color-surface-2: #18181b; /* zinc-900 */
--color-surface-3: #27272a; /* zinc-800 */
--color-muted: #71717a; /* zinc-500 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #27272a; /* zinc-800 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Cyberpunk theme — dark with neon cyan/magenta accents ────────────── */
[data-theme="cyber"] {
--color-brand: #22d3ee; /* cyan-400 — neon cyan */
--color-brand-dim: #06b6d4; /* cyan-500 */
--color-surface: #050712; /* custom near-black blue */
--color-surface-2: #0d1117; /* custom dark blue-black */
--color-surface-3: #161b27; /* custom dark blue */
--color-muted: #6272a4; /* dracula comment blue */
--color-text: #e2e8f0; /* slate-200 */
--color-border: #1e2d45; /* custom dark border */
--color-danger: #ff5555; /* dracula red */
--color-success: #50fa7b; /* dracula green */
}
html {
background-color: var(--color-surface);
color: var(--color-text);

View File

@@ -248,6 +248,12 @@
audioStore.nextChapter = nextChapter ?? null;
});
// Keep chapters list in store up to date so the layout's onended announce
// can find titles even if startPlayback() hasn't been called yet on this mount.
$effect(() => {
if (chapters.length > 0) audioStore.chapters = chapters;
});
// Keep voices in store up to date whenever prop changes.
$effect(() => {
if (voices.length > 0) audioStore.voices = voices;
@@ -1160,78 +1166,7 @@
</div>
{/if}
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->
@@ -1312,3 +1247,79 @@
{/if}
</div>
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
the fixed inset-0 positioning is never clipped by overflow-hidden or
border-radius on any ancestor wrapping the AudioPlayer component. -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<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>
</button>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}

View File

@@ -150,6 +150,9 @@ export * from './profile_theme_label.js'
export * from './profile_theme_amber.js'
export * from './profile_theme_slate.js'
export * from './profile_theme_rose.js'
export * from './profile_theme_forest.js'
export * from './profile_theme_mono.js'
export * from './profile_theme_cyber.js'
export * from './profile_theme_light.js'
export * from './profile_theme_light_slate.js'
export * from './profile_theme_light_rose.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_CyberInputs */
const en_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const ru_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Киберпанк`)
};
const id_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const pt_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const fr_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
/**
* | output |
* | --- |
* | "Cyberpunk" |
*
* @param {Profile_Theme_CyberInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_CyberInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_cyber(inputs)
if (locale === "ru") return ru_profile_theme_cyber(inputs)
if (locale === "id") return id_profile_theme_cyber(inputs)
if (locale === "pt") return pt_profile_theme_cyber(inputs)
return fr_profile_theme_cyber(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_ForestInputs */
const en_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Forest`)
};
const ru_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лес`)
};
const id_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Hutan`)
};
const pt_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Floresta`)
};
const fr_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Forêt`)
};
/**
* | output |
* | --- |
* | "Forest" |
*
* @param {Profile_Theme_ForestInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_ForestInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_forest(inputs)
if (locale === "ru") return ru_profile_theme_forest(inputs)
if (locale === "id") return id_profile_theme_forest(inputs)
if (locale === "pt") return pt_profile_theme_forest(inputs)
return fr_profile_theme_forest(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_MonoInputs */
const en_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const ru_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Моно`)
};
const id_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const pt_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const fr_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
/**
* | output |
* | --- |
* | "Mono" |
*
* @param {Profile_Theme_MonoInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_MonoInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_mono(inputs)
if (locale === "ru") return ru_profile_theme_mono(inputs)
if (locale === "id") return id_profile_theme_mono(inputs)
if (locale === "pt") return pt_profile_theme_mono(inputs)
return fr_profile_theme_mono(inputs)
});

View File

@@ -38,6 +38,9 @@
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
{ id: 'forest', color: '#4ade80' },
{ id: 'mono', color: '#f4f4f5' },
{ id: 'cyber', color: '#22d3ee' },
{ id: 'light', color: '#d97706', light: true },
{ id: 'light-slate', color: '#4f46e5', light: true },
{ id: 'light-rose', color: '#e11d48', light: true },
@@ -363,7 +366,20 @@
}}
onended={() => {
audioStore.isPlaying = false;
saveAudioTime();
// Cancel any pending debounced save and reset the position to 0 for
// the chapter that just finished. Without this, the 2s debounce fires
// after navigation and saves currentTime≈duration, causing resume to
// start at the very end next time the user returns to this chapter.
clearTimeout(audioTimeSaveTimer);
if (audioStore.slug && audioStore.chapter) {
const slug = audioStore.slug;
const chapter = audioStore.chapter;
fetch('/api/progress/audio-time', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, chapter, audioTime: 0 })
}).catch(() => {});
}
// If sleep-after-chapter is set, just pause instead of navigating
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
@@ -393,8 +409,25 @@
const text = `Chapter ${targetChapter}${titlePart}`;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.onend = doNavigate;
utterance.onerror = doNavigate;
// Guard: ensure doNavigate can only fire once even if both
// onend and the timeout fire, or onerror fires after onend.
let navigated = false;
const safeNavigate = () => {
if (navigated) return;
navigated = true;
clearTimeout(announceTimeout);
doNavigate();
};
// Hard fallback: if speechSynthesis silently drops the utterance
// (common on Chrome Android due to gesture policy, or when the
// browser is busy fetching the next chapter's audio), navigate
// anyway after a generous 8-second window.
const announceTimeout = setTimeout(safeNavigate, 8000);
utterance.onend = safeNavigate;
utterance.onerror = safeNavigate;
window.speechSynthesis.speak(utterance);
} else {
doNavigate();

View File

@@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}
// theme is optional — if provided (and non-empty) it must be a known value
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
const validThemes = ['amber', 'slate', 'rose', 'forest', 'mono', 'cyber', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && body.theme !== '' && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}

View File

@@ -311,14 +311,20 @@
return t || `Chapter ${data.chapter.number}`;
});
// Audio panel: auto-open if this chapter is already loaded/playing in the store
// Audio panel: auto-open if this chapter is already loaded/playing in the store,
// OR if auto-next is about to start it (autoStartChapter is set before navigation).
// svelte-ignore state_referenced_locally
let audioExpanded = $state(
audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number
(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number) ||
audioStore.autoStartChapter === data.chapter.number
);
$effect(() => {
// Expand automatically when the store starts playing this chapter
if (audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying) {
// Expand automatically when the store starts playing this chapter,
// or when auto-next targets this chapter (before startPlayback has run).
if (
(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying) ||
audioStore.autoStartChapter === data.chapter.number
) {
audioExpanded = true;
}
});

View File

@@ -239,6 +239,16 @@
body: JSON.stringify({ slug: book.slug, action })
});
// Optimistically add/update the history list so the drawer shows it immediately.
// If this slug was already voted (e.g. swiped twice via undo+re-swipe), replace it.
const existing = votedBooks.findIndex((v) => v.slug === book.slug);
const entry: VotedBook = { slug: book.slug, action, votedAt: new Date().toISOString(), book };
if (existing !== -1) {
votedBooks = [entry, ...votedBooks.filter((_, i) => i !== existing)];
} else {
votedBooks = [entry, ...votedBooks];
}
// Fly out
transitioning = true;
const target = flyTargets[action];
@@ -448,6 +458,7 @@
<button
type="button"
onclick={() => (showHistory = false)}
aria-label="Close history"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -145,6 +145,9 @@
{ 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: 'forest', label: () => m.profile_theme_forest(), swatch: '#4ade80' },
{ id: 'mono', label: () => m.profile_theme_mono(), swatch: '#f4f4f5' },
{ id: 'cyber', label: () => m.profile_theme_cyber(), swatch: '#22d3ee' },
{ 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 },