Compare commits

...

11 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
root
bdbec3ae16 feat: redesign discover page with bigger cards and prominent action buttons
All checks were successful
Release / Test backend (push) Successful in 1m12s
Release / Check ui (push) Successful in 2m35s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m38s
Release / Docker / runner (push) Successful in 2m35s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 2m10s
Release / Gitea Release (push) Successful in 30s
- Remove tab switcher, move history behind a modal drawer (clock icon in header with badge count)
- Increase card aspect ratio from 3/4.2 to 3/4.6 for more cover real estate
- Replace 5 small icon-only buttons with 3 large labeled buttons (Skip / Read Now / Like)
- Read Now is solid blue as the center primary CTA; Skip and Like use tinted bg with colored border
- Swipe indicators are larger (text-2xl, border-[3px], bg tint) for better visibility
- Remove swipe hint text to reclaim vertical space
- Larger title text on card (text-2xl)
2026-04-06 21:36:28 +05:00
root
c98d43a503 chore: add announce_chapter field to user_settings pb-init script
- Added announce_chapter (bool) to the user_settings create block
- Added add_field migration line for existing installs
- Also backfilled missing user_settings fields in the create block
  (theme, locale, font_family, font_size were already migrated but
  absent from the create definition)
- Migrated live prod PocketBase (pb.libnovel.cc) — field confirmed present
2026-04-06 21:18:59 +05:00
root
1f83a7c05f feat: add chapter announcing setting to audio player
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 1m51s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m24s
Release / Docker / runner (push) Successful in 2m49s
Release / Upload source maps (push) Successful in 1m25s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 30s
When enabled, the Web Speech API speaks the upcoming chapter number and
title (e.g. 'Chapter 12 — The Final Battle') between auto-next chapters,
giving an audible cue before the next narration begins.

- AudioStore.announceChapter ( boolean, default false)
- PBUserSettings.announce_chapter persisted to PocketBase
- GET/PUT /api/settings includes announceChapter field
- +layout.server.ts loads + defaults the field
- +layout.svelte applies on load, saves in debounced PUT, and fires
  SpeechSynthesisUtterance in onended before navigating (falls back to
  immediate navigation if speechSynthesis is unavailable)
- ListeningMode: 'Announce' pill added to the Speed · Auto · Sleep row
2026-04-06 21:12:10 +05:00
22 changed files with 683 additions and 395 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

@@ -245,12 +245,17 @@ create "user_library" '{
create "user_settings" '{
"name":"user_settings","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"auto_next","type":"bool"},
{"name":"voice", "type":"text"},
{"name":"speed", "type":"number"},
{"name":"updated", "type":"text"}
{"name":"session_id", "type":"text", "required":true},
{"name":"user_id", "type":"text"},
{"name":"auto_next", "type":"bool"},
{"name":"voice", "type":"text"},
{"name":"speed", "type":"number"},
{"name":"theme", "type":"text"},
{"name":"locale", "type":"text"},
{"name":"font_family", "type":"text"},
{"name":"font_size", "type":"number"},
{"name":"announce_chapter","type":"bool"},
{"name":"updated", "type":"text"}
]}'
create "user_subscriptions" '{
@@ -345,6 +350,11 @@ add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
add_field "chapters_idx" "created" "date"
add_field "user_settings" "theme" "text"
add_field "user_settings" "locale" "text"
add_field "user_settings" "font_family" "text"
add_field "user_settings" "font_size" "number"
add_field "user_settings" "announce_chapter" "bool"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \

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

@@ -101,6 +101,13 @@ class AudioStore {
*/
autoNext = $state(false);
/**
* When true, announces the upcoming chapter number and title via the
* Web Speech API before auto-next navigation fires.
* e.g. "Chapter 12 — The Final Battle"
*/
announceChapter = $state(false);
/**
* The next chapter number for the currently playing chapter, or null if
* there is no next chapter. Written by the chapter page's AudioPlayer.

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

@@ -618,8 +618,8 @@
{/if}
</div>
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
<div class="flex items-center justify-center gap-2 shrink-0">
<!-- Secondary controls: unified single row — Speed · Auto · Announce · Sleep -->
<div class="flex items-center justify-center gap-2 shrink-0 flex-wrap">
<!-- Speed — segmented pill -->
<div class="flex items-center gap-0.5 bg-(--color-surface-2) rounded-full px-1.5 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
@@ -661,6 +661,25 @@
{/if}
</button>
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
Announce
</button>
<!-- Sleep timer pill -->
<button
type="button"

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

@@ -75,6 +75,7 @@ export interface PBUserSettings {
locale?: string;
font_family?: string;
font_size?: number;
announce_chapter?: boolean;
updated?: string;
}
@@ -998,7 +999,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -1017,6 +1018,7 @@ export async function saveSettings(
if (settings.locale !== undefined) payload.locale = settings.locale;
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
if (userId) payload.user_id = userId;
if (existing) {

View File

@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
@@ -28,7 +28,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size || 1.0
fontSize: row.font_size || 1.0,
announceChapter: row.announce_chapter ?? false
};
}
} catch (e) {

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 },
@@ -107,6 +110,7 @@
audioStore.autoNext = data.settings.autoNext;
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
audioStore.announceChapter = data.settings.announceChapter ?? false;
}
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
@@ -128,6 +132,7 @@
const theme = currentTheme;
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
const announceChapter = audioStore.announceChapter;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
@@ -138,7 +143,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -361,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;
@@ -377,9 +395,43 @@
// Store the target chapter number so only the newly-mounted AudioPlayer
// for that chapter reacts — not the outgoing chapter's component.
audioStore.autoStartChapter = targetChapter;
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
// Announce the upcoming chapter via Web Speech API if enabled.
const doNavigate = () => {
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
};
if (audioStore.announceChapter && typeof window !== 'undefined' && 'speechSynthesis' in window) {
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
const titlePart = nextInfo?.title ? ` ${nextInfo.title}` : '';
const text = `Chapter ${targetChapter}${titlePart}`;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
// 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();
}
}
}}
preload="metadata"

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ locals }) => {
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size || 1.0
fontSize: settings?.font_size || 1.0,
announceChapter: settings?.announce_chapter ?? false
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -28,7 +29,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -44,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(', ')}`);
}
@@ -67,6 +68,11 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}
// announceChapter is optional boolean
if (body.announceChapter !== undefined && typeof body.announceChapter !== 'boolean') {
error(400, 'Invalid announceChapter — must be boolean');
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {

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

@@ -86,7 +86,7 @@
let transitioning = $state(false);
let showPreview = $state(false);
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
let activeTab = $state<'discover' | 'history'>('discover');
let showHistory = $state(false);
// svelte-ignore state_referenced_locally
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
@@ -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];
@@ -426,49 +436,126 @@
</div>
{/if}
<!-- ── History drawer ─────────────────────────────────────────────────────────── -->
{#if showHistory}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
role="presentation"
onclick={() => (showHistory = false)}
onkeydown={(e) => { if (e.key === 'Escape') showHistory = false; }}
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between p-5 border-b border-(--color-border) flex-shrink-0">
<h3 class="font-bold text-(--color-text)">History {#if votedBooks.length}<span class="text-(--color-muted) font-normal text-sm">({votedBooks.length})</span>{/if}</h3>
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-4 space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{:else}
{#each votedBooks as v (v.slug)}
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-xl p-3">
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-2) flex-shrink-0"></div>
{/if}
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
Clear all history
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-4 pt-8 pb-6 select-none">
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-3 pt-6 pb-6 select-none">
<!-- Header -->
<div class="w-full max-w-sm flex items-center justify-between mb-6">
<div class="w-full max-w-sm flex items-center justify-between mb-4">
<div>
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
{#if !deckEmpty}
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
{/if}
</div>
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) 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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</button>
<div class="flex items-center gap-1">
<!-- History button -->
<button
type="button"
onclick={() => (showHistory = true)}
title="History"
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{#if votedBooks.length}
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
{votedBooks.length > 9 ? '9+' : votedBooks.length}
</span>
{/if}
</button>
<!-- Preferences button -->
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</button>
</div>
</div>
<!-- Tab switcher -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
<button
type="button"
onclick={() => (activeTab = 'discover')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
History {#if votedBooks.length}({votedBooks.length}){/if}
</button>
</div>
{#if activeTab === 'discover'}
{#if deckEmpty}
<!-- Empty state -->
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
@@ -501,8 +588,9 @@
</div>
{:else}
{@const book = currentBook!}
<!-- Card stack -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
<!-- Card stack — fills available width, taller ratio -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.6;">
<!-- Back card 2 -->
{#if nextNextBook}
@@ -561,9 +649,9 @@
{/if}
<!-- Bottom gradient + info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none"></div>
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
{#if book.author}
<p class="text-white/70 text-sm mb-2">{book.author}</p>
{/if}
@@ -582,164 +670,91 @@
<!-- LIKE indicator (right swipe) -->
<div
class="absolute top-8 right-6 px-3 py-1.5 rounded-lg border-2 border-green-400 rotate-[-15deg] pointer-events-none"
class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-green-400 font-black text-lg tracking-widest">LIKE</span>
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
</div>
<!-- SKIP indicator (left swipe) -->
<div
class="absolute top-8 left-6 px-3 py-1.5 rounded-lg border-2 border-red-400 rotate-[15deg] pointer-events-none"
class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-red-400 font-black text-lg tracking-widest">SKIP</span>
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
</div>
<!-- READ NOW indicator (swipe up) -->
<div
class="absolute top-8 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-blue-400 pointer-events-none"
class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-blue-400 font-black text-lg tracking-widest">READ NOW</span>
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
</div>
<!-- NOPE indicator (swipe down) -->
<div
class="absolute bottom-28 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-(--color-muted) pointer-events-none"
class="absolute bottom-32 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-zinc-400 pointer-events-none bg-black/20"
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
<span class="text-zinc-300 font-black text-2xl tracking-widest">NOPE</span>
</div>
</div>
{/if}
</div>
<!-- Action buttons -->
<div class="w-full max-w-sm flex items-center justify-center gap-4 mt-6">
<!-- Skip (left) -->
<!-- Action buttons — 3 prominent labeled buttons -->
<div class="w-full max-w-sm flex items-stretch gap-3 mt-5">
<!-- Skip -->
<button
type="button"
onclick={() => doAction('skip')}
disabled={animating}
title="Skip"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-red-400 hover:bg-red-400/10 hover:border-red-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-red-500/15 border border-red-500/30 text-red-400
hover:bg-red-500/25 hover:border-red-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="text-xs font-bold tracking-wide">Skip</span>
</button>
<!-- Read Now (up) -->
<!-- Read Now — center, most prominent -->
<button
type="button"
onclick={() => doAction('read_now')}
disabled={animating}
title="Read Now"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-blue-400 hover:bg-blue-400/10 hover:border-blue-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-blue-500 text-white
hover:bg-blue-400
active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span class="text-xs font-bold tracking-wide">Read Now</span>
</button>
<!-- Preview (center) -->
<button
type="button"
onclick={() => (showPreview = true)}
disabled={animating}
title="Details"
class="w-10 h-10 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<!-- Like (right) -->
<!-- Like -->
<button
type="button"
onclick={() => doAction('like')}
disabled={animating}
title="Add to Library"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-success) hover:bg-green-400/10 hover:border-green-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-green-500/15 border border-green-500/30 text-green-400
hover:bg-green-500/25 hover:border-green-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</button>
<!-- Nope (down) -->
<button
type="button"
onclick={() => doAction('nope')}
disabled={animating}
title="Never show again"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-muted)/60 hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
<span class="text-xs font-bold tracking-wide">Like</span>
</button>
</div>
<!-- Swipe hint (shown briefly) -->
<p class="mt-4 text-xs text-(--color-muted)/50 text-center">
Swipe or tap buttons · Tap card for details
</p>
{/if}
{/if}
{#if activeTab === 'history'}
<div class="w-full max-w-sm space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{:else}
{#each votedBooks as v (v.slug)}
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
<!-- Cover thumbnail -->
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
{/if}
<!-- Info -->
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<!-- Undo button -->
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
Clear all history
</button>
{/if}
</div>
{/if}
</div>

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