Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c900fc476f | ||
|
|
d612b40fdb | ||
|
|
faa4c42f20 | ||
|
|
17fa913ba9 | ||
|
|
95f45a5f13 | ||
|
|
2ed37f78c7 | ||
|
|
963ecdd89b | ||
|
|
12963342bb | ||
|
|
bdbec3ae16 | ||
|
|
c98d43a503 | ||
|
|
1f83a7c05f |
@@ -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:
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/profile_theme_cyber.js
Normal file
44
ui/src/lib/paraglide/messages/profile_theme_cyber.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/profile_theme_forest.js
Normal file
44
ui/src/lib/paraglide/messages/profile_theme_forest.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/profile_theme_mono.js
Normal file
44
ui/src/lib/paraglide/messages/profile_theme_mono.js
Normal 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)
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user