Compare commits

...

7 Commits

Author SHA1 Message Date
Admin
55df88c3e5 fix(player): remove duplicate inline player and declutter bottom bar
Some checks failed
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m47s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / backend (push) Successful in 3m11s
Release / Docker / runner (push) Successful in 2m42s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Failing after 1m35s
Release / Gitea Release (push) Has been skipped
When the mini-player is active on the current chapter, the collapsible
'Listen to this chapter' panel now shows a brief note instead of rendering
a full second AudioPlayer.

Bottom bar track info column: removed chapter title and book title lines
so the time display fits on one line without crowding the controls.
2026-04-05 20:31:01 +05:00
Admin
eb137fdbf5 fix: source maps — ship pre-built artifact with injected debug IDs to Docker
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m38s
Release / Docker / caddy (push) Successful in 49s
Release / Docker / backend (push) Successful in 2m54s
Release / Docker / runner (push) Successful in 2m52s
Release / Upload source maps (push) Successful in 1m39s
Release / Docker / ui (push) Failing after 1m34s
Release / Gitea Release (push) Has been skipped
The docker-ui job was rebuilding the UI from scratch inside Docker, producing
chunk hashes and JS files that didn't match the source maps uploaded to
GlitchTip, causing all stack traces to appear minified.

Fix:
- ui/Dockerfile: add PREBUILT=1 ARG; skip npm run build when set
- release.yaml upload-sourcemaps: re-upload artifact after sentry-cli inject
- release.yaml docker-ui: download injected artifact into ui/build/ and pass
  PREBUILT=1 so Docker reuses the exact same JS files whose debug IDs are in
  GlitchTip
2026-04-05 20:25:27 +05:00
Admin
385c9cd8f2 feat(reader): voice modal with search, chapter search, comments collapse, audio stream fix
All checks were successful
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 4m15s
Release / Docker / runner (push) Successful in 4m28s
Release / Upload source maps (push) Successful in 53s
Release / Docker / ui (push) Successful in 7m30s
Release / Gitea Release (push) Successful in 49s
- ListeningMode: replace inline voice dropdown with full-screen modal + voice search
- ListeningMode: add chapter search box (shown when > 6 chapters) + click-to-play via autoStartChapter
- CommentsSection: collapse by default when empty, auto-expand once comments load
- AudioPlayer: always use streaming endpoint for Kokoro/PocketTTS (backend deduplicates via AudioExists)
2026-04-05 20:20:19 +05:00
Admin
e3bb19892c feat: add AI Jobs admin page, cache model lists, improve image-gen 502 UX
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m36s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 4m27s
Release / Docker / runner (push) Successful in 3m32s
Release / Upload source maps (push) Successful in 44s
Release / Docker / ui (push) Successful in 2m46s
Release / Gitea Release (push) Successful in 58s
- Add /admin/ai-jobs page with live-polling jobs table, status badges, progress bars, and cancel action
- Add listAIJobs() helper in pocketbase.ts; AIJob type
- Add POST /api/admin/ai-jobs/[id]/cancel SvelteKit proxy
- Add ai-jobs link to admin sidebar nav (all 5 locales)
- Cache image-gen and text-gen model lists in Valkey (10 min TTL) via scraper.ts helpers
- Cache changelog Gitea API response (5 min TTL)
- Improve 502/504 error message on image-gen page with CF AI timeout hint
- Show CF AI timeout warning in advanced options when a Cloudflare/FLUX model is selected
2026-04-05 20:07:54 +05:00
Admin
6ca704ec9a fix: use upload/download-artifact@v3 for Gitea GHES compatibility
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 5m3s
Release / Docker / runner (push) Successful in 3m41s
Release / Upload source maps (push) Successful in 1m44s
Release / Docker / ui (push) Successful in 4m8s
Release / Gitea Release (push) Successful in 2m42s
2026-04-05 18:15:43 +05:00
Admin
2bdb5e29af perf: reuse UI build artifact for source map upload in CI
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Failing after 51s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 4m28s
Release / Docker / runner (push) Successful in 4m56s
Release / Gitea Release (push) Has been skipped
Pass build output from check-ui to upload-sourcemaps via artifact
instead of rebuilding from scratch. Saves ~4-5 min (npm ci + npm run build)
from the upload-sourcemaps job.
2026-04-05 18:01:00 +05:00
Admin
222627a18c refactor: clean up chapter reader UI — nav, audio, lang switcher, bottom CTA
- Consolidate top nav into a single row: ← back | ← → chapter arrows | settings gear
  Removes the duplicate prev/next buttons that appeared at both top and bottom
- Language switcher moved inline into chapter meta line (after word count);
  lock icons made smaller and less distracting for free users; removes the
  separate "Upgrade to Pro" text link
- Audio player wrapped in a collapsible "Listen to this chapter" panel;
  auto-expands if audio is already playing for the chapter, collapsed otherwise
  so content is immediately visible on page load
- Bottom nav redesigned: next chapter is a full-width card CTA with hover
  accent, previous is a small secondary text link — clear visual hierarchy
- Remove floating gear button (settings now triggered from top nav settings icon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:57:44 +05:00
25 changed files with 1211 additions and 542 deletions

View File

@@ -55,6 +55,13 @@ jobs:
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: ui-build
path: ui/build
retention-days: 1
# ── docker: backend ───────────────────────────────────────────────────────────
docker-backend:
name: Docker / backend
@@ -140,29 +147,18 @@ jobs:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Compute release version (strip leading v)
id: ver
run: |
V="${{ gitea.ref_name }}"
echo "version=${V#v}" >> "$GITHUB_OUTPUT"
- name: Build with source maps
run: npm run build
- 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
@@ -175,6 +171,13 @@ jobs:
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:
@@ -230,6 +233,12 @@ jobs:
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
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -259,6 +268,7 @@ jobs:
BUILD_VERSION=${{ steps.meta.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

View File

@@ -21,7 +21,11 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
# already been compiled (and debug IDs injected) by a prior job. The caller
# must copy the pre-built build/ into the Docker context before building.
ARG PREBUILT=0
RUN [ "$PREBUILT" = "1" ] || npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles most server-side code, but packages with dynamic

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "AI Jobs",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tâches IA",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tugas AI",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tarefas de IA",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Задачи ИИ",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",

View File

@@ -575,11 +575,14 @@
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
// use the streaming endpoint — audio starts playing within seconds while
// generation runs and MinIO is populated concurrently.
// Skip when enqueued=true to avoid double-generation with the async runner.
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
// starts playing within seconds. The stream handler checks MinIO first
// (fast redirect if already cached) and otherwise generates + uploads
// concurrently. Even if the async runner is already working on this
// chapter, the stream will redirect to MinIO the moment the runner
// finishes — no harmful double-generation occurs because the backend
// deduplications via AudioExists on the next request.
if (!voice.startsWith('cfai:')) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.

View File

@@ -241,311 +241,321 @@
const totalCount = $derived(
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
);
// ── Collapsed state ───────────────────────────────────────────────────────
// Hidden by default when there are no comments; expand on user tap.
let expanded = $state(false);
const hasComments = $derived(!loading && comments.length > 0);
// Auto-expand once comments load in
$effect(() => {
if (hasComments) expanded = true;
});
</script>
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-(--color-text)">
{#if !expanded && !hasComments && !loading}
<!-- Collapsed: just a subtle link — no wasted real-estate for empty chapters -->
<button
type="button"
onclick={() => (expanded = true)}
class="flex items-center gap-1.5 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"/>
</svg>
{m.comments_heading()}
{#if !loading && totalCount > 0}
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')}
>{m.comments_top()}</Button>
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')}
>{m.comments_new()}</Button>
</div>
{/if}
</div>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<Textarea
bind:value={newBody}
placeholder={m.comments_placeholder()}
rows={3}
/>
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="default"
size="sm"
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? m.comments_posting() : m.comments_submit()}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
{m.comments_login_suffix()}
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-(--color-danger)">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-(--color-muted)">{m.comments_empty()}</p>
</button>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<!-- Expanded: full comments section -->
<div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-(--color-text)">
{m.comments_heading()}
{#if !loading && totalCount > 0}
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title={m.comments_vote_up()}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<!-- Downvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title={m.comments_vote_down()}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
<!-- Reply button -->
{#if isLoggedIn}
</h2>
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
{m.comments_reply()}
</Button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')}
>{m.comments_top()}</Button>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{m.comments_delete()}
</Button>
{/if}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')}
>{m.comments_new()}</Button>
</div>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<Textarea
bind:value={replyBody}
bind:value={newBody}
placeholder={m.comments_placeholder()}
rows={2}
rows={3}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="ghost"
variant="default"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>{m.common_cancel()}</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? m.comments_posting() : m.comments_reply()}
</Button>
</div>
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? m.comments_posting() : m.comments_submit()}
</Button>
</div>
</div>
{/if}
</div>
{:else}
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
{m.comments_login_suffix()}
</p>
{/if}
</div>
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-(--color-danger)">{loadError}</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
<div class={cn('rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2', deleting && 'opacity-50')}>
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Reply body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title={m.comments_vote_up()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title={m.comments_vote_down()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
{#if isLoggedIn}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null; replyBody = ''; replyError = '';
} else {
replyingTo = comment.id; replyBody = ''; replyError = '';
}
}}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{m.comments_delete()}
</Button>
{/if}
</div>
</div>
{/each}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
{m.comments_reply()}
</Button>
{/if}
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{m.comments_delete()}
</Button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<Textarea
bind:value={replyBody}
placeholder={m.comments_placeholder()}
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>{m.common_cancel()}</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? m.comments_posting() : m.comments_reply()}
</Button>
</div>
</div>
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class={cn('rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5', replyDeleting && 'opacity-50')}>
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
</div>
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title={m.comments_vote_up()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title={m.comments_vote_down()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{m.comments_delete()}
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { audioStore } from '$lib/audio.svelte';
import { cn } from '$lib/utils';
import { goto } from '$app/navigation';
import type { Voice } from '$lib/types';
interface Props {
@@ -16,10 +17,30 @@
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
let showVoicePanel = $state(false);
let showVoiceModal = $state(false);
let voiceSearch = $state('');
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
// ── Voice search filtering ────────────────────────────────────────────────
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
const filteredPocket = $derived(pocketVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
// ── Chapter search ────────────────────────────────────────────────────────
let chapterSearch = $state('');
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function voiceLabel(v: Voice | string): string {
if (typeof v === 'string') {
const found = voices.find((x) => x.id === v);
@@ -60,7 +81,15 @@
function selectVoice(voiceId: string) {
stopSample();
audioStore.voice = voiceId;
showVoicePanel = false;
showVoiceModal = false;
voiceSearch = '';
}
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
// ── Speed ────────────────────────────────────────────────────────────────
@@ -134,7 +163,7 @@
$effect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showVoicePanel) { showVoicePanel = false; }
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
else { onclose(); }
}
}
@@ -174,10 +203,10 @@
<!-- Voice selector button -->
<button
type="button"
onclick={() => (showVoicePanel = !showVoicePanel)}
onclick={() => (showVoiceModal = !showVoiceModal)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoicePanel
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
@@ -189,50 +218,108 @@
</button>
</div>
<!-- Voice panel (inline dropdown below header) -->
{#if showVoicePanel && voices.length > 0}
<div class="relative mx-4 mb-2 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-3 z-10 overflow-y-auto max-h-56 shrink-0">
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Select Voice</p>
<button type="button" onclick={() => { stopSample(); showVoicePanel = false; }} class="text-(--color-muted) hover:text-(--color-text) transition-colors" aria-label="Close voice panel">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
<!-- Voice modal (full-screen overlay) -->
{#if showVoiceModal && voices.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-70 flex flex-col"
style="background: var(--color-surface);"
>
<!-- Modal header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
onclick={() => { stopSample(); showVoiceModal = false; voiceSearch = ''; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close voice 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">Select Voice</span>
</div>
{#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]}
{#if group.length > 0}
<p class="text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0">{label}</p>
<div class="flex flex-wrap gap-1.5">
<!-- Search input -->
<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 voices…"
bind:value={voiceSearch}
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>
<!-- Voice list -->
<div class="flex-1 overflow-y-auto">
{#each ([['Kokoro', filteredKokoro], ['Pocket TTS', filteredPocket], ['CF AI', filteredCfai]] as [string, Voice[]][]) as [label, group]}
{#if group.length > 0}
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2 sticky top-0 bg-(--color-surface) border-b border-(--color-border)/50">{label}</p>
{#each group as v (v.id)}
<div class="flex items-center rounded-lg border overflow-hidden text-xs
{audioStore.voice === v.id
? 'border-(--color-brand) bg-(--color-brand)/10'
: 'border-(--color-border) bg-(--color-surface-3)'}">
<div
class={cn(
'flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors',
audioStore.voice === v.id
? 'bg-(--color-brand)/8'
: 'hover:bg-(--color-surface-2)'
)}
>
<!-- Select voice -->
<button
type="button"
onclick={() => selectVoice(v.id)}
class="px-2 py-1 font-medium transition-colors
{audioStore.voice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>{voiceLabel(v)}</button>
class="flex-1 flex items-center gap-3 text-left"
>
<!-- Selected indicator -->
<span class={cn(
'w-4 h-4 shrink-0 rounded-full border-2 flex items-center justify-center transition-colors',
audioStore.voice === v.id
? 'border-(--color-brand) bg-(--color-brand)'
: 'border-(--color-border)'
)}>
{#if audioStore.voice === v.id}
<svg class="w-2 h-2 text-(--color-surface)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</span>
<span class={cn(
'text-sm',
audioStore.voice === v.id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{voiceLabel(v)}</span>
</button>
<!-- Sample play button -->
<button
type="button"
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
class="px-1.5 py-1 border-l border-(--color-border) transition-colors
{samplePlayingVoice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
onclick={() => playSample(v.id)}
class={cn(
'shrink-0 p-2 rounded-full transition-colors',
samplePlayingVoice === v.id
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
>
{#if samplePlayingVoice === v.id}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
</div>
{/each}
</div>
{/if}
{/each}
{#if filteredKokoro.length === 0 && filteredPocket.length === 0 && filteredCfai.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No voices match "{voiceSearch}"</p>
{/if}
{/each}
</div>
</div>
{/if}
@@ -425,14 +512,32 @@
<!-- Chapter list -->
{#if audioStore.chapters.length > 0}
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2.5 border-b border-(--color-border)">Chapters</p>
<!-- Header + search -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-(--color-border)">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider shrink-0">Chapters</p>
{#if audioStore.chapters.length > 6}
<div class="relative flex-1">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 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…"
bind:value={chapterSearch}
class="w-full pl-6 pr-2 py-0.5 text-xs bg-(--color-surface-3) border border-(--color-border) rounded-md text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
{/if}
</div>
<div class="overflow-y-auto max-h-64">
{#each audioStore.chapters as ch (ch.number)}
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={onclose}
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
{#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-2.5 text-xs transition-colors hover:bg-(--color-surface-3) text-left',
ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'
)}
>
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
@@ -441,8 +546,11 @@
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-4 text-xs text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}

View File

@@ -374,6 +374,7 @@ export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_ai_jobs.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Ai_JobsInputs */
const en_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const ru_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Задачи ИИ`)
};
const id_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tugas AI`)
};
const pt_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tarefas de IA`)
};
const fr_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tâches IA`)
};
/**
* | output |
* | --- |
* | "AI Jobs" |
*
* @param {Admin_Nav_Ai_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_ai_jobs = /** @type {((inputs?: Admin_Nav_Ai_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Ai_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_ai_jobs(inputs)
if (locale === "ru") return ru_admin_nav_ai_jobs(inputs)
if (locale === "id") return id_admin_nav_ai_jobs(inputs)
if (locale === "pt") return pt_admin_nav_ai_jobs(inputs)
return fr_admin_nav_ai_jobs(inputs)
});

View File

@@ -14,6 +14,23 @@ const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AIJob {
id: string;
kind: string;
slug: string;
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
from_item: number;
to_item: number;
items_done: number;
items_total: number;
model: string;
payload: string;
error_message?: string;
started?: string;
finished?: string;
heartbeat_at?: string;
}
export interface Book {
id: string;
slug: string;
@@ -2162,3 +2179,13 @@ export async function getUserStats(
streak
};
}
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
/**
* List all AI jobs from PocketBase, sorted by started descending.
* No caching — admin views always want fresh data.
*/
export async function listAIJobs(): Promise<AIJob[]> {
return listAll<AIJob>('ai_jobs', '', '-started');
}

View File

@@ -12,6 +12,7 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import * as cache from '$lib/server/cache';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
@@ -33,6 +34,50 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
// ─── Cached admin model lists ─────────────────────────────────────────────────
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change
/**
* Fetch image-gen model list from the Go backend with a 10-minute cache.
* Returns an empty array on error (callers should surface a warning).
*/
export async function listImageModels<T>(): Promise<T[]> {
const key = 'backend:models:image-gen';
const cached = await cache.get<T[]>(key);
if (cached) return cached;
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) return [];
const data = await res.json();
const models = (data.models ?? []) as T[];
await cache.set(key, models, MODELS_CACHE_TTL);
return models;
} catch {
return [];
}
}
/**
* Fetch text-gen model list from the Go backend with a 10-minute cache.
* Returns an empty array on error.
*/
export async function listTextModels<T>(): Promise<T[]> {
const key = 'backend:models:text-gen';
const cached = await cache.get<T[]>(key);
if (cached) return cached;
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) return [];
const data = await res.json();
const models = (data.models ?? []) as T[];
await cache.set(key, models, MODELS_CACHE_TTL);
return models;
} catch {
return [];
}
}
// ─── Response types ───────────────────────────────────────────────────────────
/**

View File

@@ -857,12 +857,6 @@
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
>
{#if audioStore.chapterTitle}
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
{#if audioStore.bookTitle}
<p class="text-xs text-(--color-muted) truncate leading-tight">{audioStore.bookTitle}</p>
{/if}
{#if audioStore.status === 'generating'}
<p class="text-xs text-(--color-brand) leading-tight">
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}

View File

@@ -9,7 +9,8 @@
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() },
{ href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() }
];
const externalLinks = [

View File

@@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { listAIJobs, type AIJob } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export type { AIJob };
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const jobs = await listAIJobs().catch((e): AIJob[] => {
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
return [];
});
return { jobs };
};

View File

@@ -0,0 +1,326 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
import { cn } from '$lib/utils';
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
// Keep in sync on server reloads
$effect(() => {
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
}, 3000);
return () => clearInterval(id);
});
// ── Filter / search ───────────────────────────────────────────────────────────
let query = $state('');
let statusFilter = $state<string>('all');
const STATUS_OPTIONS = ['all', 'pending', 'running', 'done', 'failed', 'cancelled'] as const;
let filteredJobs = $derived(
jobs.filter((j) => {
const q = query.trim().toLowerCase();
const matchesQ =
!q ||
j.slug.toLowerCase().includes(q) ||
j.kind.toLowerCase().includes(q) ||
j.model.toLowerCase().includes(q) ||
(j.error_message ?? '').toLowerCase().includes(q);
const matchesStatus = statusFilter === 'all' || j.status === statusFilter;
return matchesQ && matchesStatus;
})
);
// ── Stats ────────────────────────────────────────────────────────────────────
let stats = $derived({
total: jobs.length,
running: jobs.filter((j) => j.status === 'running').length,
pending: jobs.filter((j) => j.status === 'pending').length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length
});
// ── Cancel ────────────────────────────────────────────────────────────────────
let cancellingId = $state<string | null>(null);
let cancelError = $state('');
async function cancelJob(id: string) {
if (cancellingId) return;
cancellingId = id;
cancelError = '';
try {
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelError = body.error ?? `Error ${res.status}`;
} else {
await invalidateAll();
}
} catch {
cancelError = 'Network error.';
} finally {
cancellingId = null;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
function statusBg(status: string) {
if (status === 'done') return 'bg-green-400/10 text-green-400';
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
if (status === 'pending') return 'bg-sky-400/10 text-sky-400';
if (status === 'failed') return 'bg-(--color-danger)/10 text-(--color-danger)';
if (status === 'cancelled') return 'bg-zinc-700/50 text-(--color-muted)';
return 'bg-zinc-700/50 text-(--color-text)';
}
function kindLabel(kind: string) {
const labels: Record<string, string> = {
'chapter-names': 'Chapter Names',
'batch-covers': 'Batch Covers',
'chapter-covers': 'Chapter Covers',
'refresh-metadata': 'Refresh Metadata'
};
return labels[kind] ?? kind;
}
function fmtDate(s: string | undefined) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function duration(started: string | undefined, finished: string | undefined) {
if (!started || !finished) return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
function progress(job: AIJob) {
if (!job.items_total) return null;
return Math.round((job.items_done / job.items_total) * 100);
}
</script>
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
</div>
<button
onclick={() => invalidateAll()}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
<!-- Stats bar -->
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
{#each [
{ label: 'Total', value: stats.total, color: 'text-(--color-text)' },
{ label: 'Running', value: stats.running, color: 'text-(--color-brand)' },
{ label: 'Pending', value: stats.pending, color: 'text-sky-400' },
{ label: 'Done', value: stats.done, color: 'text-green-400' },
{ label: 'Failed', value: stats.failed, color: 'text-(--color-danger)' }
] as stat}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-4 py-3">
<p class="text-xs text-(--color-muted) mb-1">{stat.label}</p>
<p class={cn('text-xl font-bold tabular-nums', stat.color)}>{stat.value}</p>
</div>
{/each}
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 items-center">
<input
type="search"
placeholder="Search slug, kind, model…"
bind:value={query}
class="flex-1 min-w-48 px-3 py-1.5 rounded-md bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<div class="flex gap-1 flex-wrap">
{#each STATUS_OPTIONS as s}
<button
onclick={() => (statusFilter = s)}
class={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize',
statusFilter === s
? 'bg-(--color-brand) text-black'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
{s}
</button>
{/each}
</div>
</div>
<!-- Cancel error -->
{#if cancelError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
{/if}
<!-- Jobs table -->
{#if filteredJobs.length === 0}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
<p class="text-(--color-muted) text-sm">
{jobs.length === 0 ? 'No AI jobs found.' : 'No jobs match your filters.'}
</p>
</div>
{:else}
<div class="rounded-lg border border-(--color-border) overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Status</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Kind</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Slug</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden sm:table-cell">Model</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden md:table-cell">Progress</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Started</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Duration</th>
<th class="px-4 py-2.5 text-right text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)">
{#each filteredJobs as job (job.id)}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
<!-- Status badge -->
<td class="px-4 py-3">
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
{job.status}
</span>
</td>
<!-- Kind -->
<td class="px-4 py-3 font-medium text-(--color-text)">
{kindLabel(job.kind)}
</td>
<!-- Slug -->
<td class="px-4 py-3 max-w-[12rem]">
{#if job.slug}
<a
href="/admin/image-gen?slug={job.slug}"
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
>
{job.slug}
</a>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
<!-- Model -->
<td class="px-4 py-3 hidden sm:table-cell">
<span class="font-mono text-xs text-(--color-muted) truncate block max-w-[10rem]" title={job.model}>
{job.model || '—'}
</span>
</td>
<!-- Progress -->
<td class="px-4 py-3 hidden md:table-cell">
{#if job.items_total > 0}
{@const pct = progress(job)}
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 rounded-full bg-(--color-border) overflow-hidden">
<div
class={cn(
'h-full rounded-full transition-all',
job.status === 'done' ? 'bg-green-400' :
job.status === 'failed' ? 'bg-(--color-danger)' :
'bg-(--color-brand)'
)}
style="width: {pct}%"
></div>
</div>
<span class="text-xs text-(--color-muted) tabular-nums whitespace-nowrap">
{job.items_done}/{job.items_total}
</span>
</div>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
<!-- Started -->
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-xs text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</span>
</td>
<!-- Duration -->
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-xs text-(--color-muted) whitespace-nowrap tabular-nums">
{duration(job.started, job.finished)}
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingId === job.id}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if job.error_message}
<span
class="text-xs text-(--color-danger) max-w-[12rem] truncate"
title={job.error_message}
>
{job.error_message}
</span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="text-xs text-(--color-muted)">
Showing {filteredJobs.length} of {jobs.length} jobs
</p>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { listImageModels } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
@@ -12,13 +12,10 @@ export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
let imgModels: ImageModelInfo[] = [];
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (res.ok) {
const data = await res.json();
imgModels = (data.models ?? []) as ImageModelInfo[];
}
imgModels = await listImageModels<ImageModelInfo>();
} catch (e) {
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
}
return { imgModels };
};

View File

@@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import * as cache from '$lib/server/cache';
export interface Release {
id: number;
@@ -13,7 +14,15 @@ export interface Release {
const GITEA_RELEASES_URL =
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached };
}
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
@@ -22,7 +31,9 @@ export const load: PageServerLoad = async ({ fetch }) => {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
const releases: Release[] = await res.json();
return { releases: releases.filter((r) => !r.draft) };
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}

View File

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { listImageModels } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
@@ -21,23 +21,18 @@ export interface BookSummary {
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as ImageModelInfo[];
})(),
const [models, booksResult] = await Promise.allSettled([
listImageModels<ImageModelInfo>(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
if (models.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',

View File

@@ -355,7 +355,13 @@
const body = await res.json().catch(() => ({}));
if (!res.ok) {
genError = body.error ?? body.message ?? `Error ${res.status}`;
if (res.status === 502 || res.status === 504) {
genError =
body.error ??
`Generation timed out (${res.status}). FLUX models can take 60120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
} else {
genError = body.error ?? body.message ?? `Error ${res.status}`;
}
return;
}
@@ -754,6 +760,13 @@
{#if showAdvanced}
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
<!-- Cloudflare AI timeout warning -->
{#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')}
<p class="text-xs text-amber-400/80 bg-amber-400/10 rounded px-2.5 py-1.5">
Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits.
</p>
{/if}
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">

View File

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { listTextModels } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
@@ -18,23 +18,18 @@ export interface TextModelInfo {
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as TextModelInfo[];
})(),
const [models, booksResult] = await Promise.allSettled([
listTextModels<TextModelInfo>(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
if (models.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]

View File

@@ -0,0 +1,29 @@
/**
* POST /api/admin/ai-jobs/[id]/cancel
*
* Admin-only proxy to the Go backend's AI job cancel endpoint.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { id } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
} catch (e) {
log.error('admin/ai-jobs/cancel', 'backend proxy error', { id, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -286,23 +286,36 @@
const wordCount = $derived(
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
);
// Audio panel: auto-open if this chapter is already loaded/playing in the store
// svelte-ignore state_referenced_locally
let audioExpanded = $state(
audioStore.slug === data.book.slug && audioStore.chapter === 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) {
audioExpanded = true;
}
});
</script>
<svelte:head>
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}{data.book.title} — libnovel</title>
</svelte:head>
<!-- Reading progress bar (scroll mode) -->
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
{#if layout.readMode === 'scroll'}
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
{/if}
<!-- Top nav (hidden in focus mode) -->
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
{#if !layout.focusMode}
<div class="flex items-center justify-between mb-6 gap-4">
<div class="flex items-center justify-between mb-8 gap-2">
<!-- Left: back to chapters list -->
<a
href="/books/{data.book.slug}/chapters"
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-text) text-sm transition-colors shrink-0"
>
<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="M15 19l-7-7 7-7" />
@@ -310,148 +323,187 @@
{m.reader_back_to_chapters()}
</a>
<div class="flex gap-2">
<!-- Right: prev/next chapter arrows + settings -->
<div class="flex items-center gap-1">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
title="{m.reader_chapter_n({ n: String(data.prev) })}"
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_chapter_n({ n: String(data.prev) })}
<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="M15 19l-7-7 7-7" />
</svg>
</a>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
title="{m.reader_chapter_n({ n: String(data.next) })}"
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_chapter_n({ n: String(data.next) })} &rarr;
<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="M9 5l7 7-7 7" />
</svg>
</a>
{/if}
{#if settingsCtx}
<button
type="button"
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="p-2 rounded-lg transition-colors hover:bg-(--color-surface-2) {settingsPanelOpen ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
<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>
{/if}
</div>
</div>
<!-- Chapter heading -->
<div class="mb-4">
<h1 class="text-xl font-bold text-(--color-text)">
<!-- Chapter heading + meta + language switcher -->
<div class="mb-6">
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
</h1>
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs mt-1">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-50 mx-1">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min read
</p>
{/if}
</div>
{/if}
<!-- Language switcher (not shown for preview chapters or focus mode) -->
{#if !data.isPreview && !layout.focusMode}
<div class="flex items-center gap-2 mb-6 flex-wrap">
<span class="text-(--color-muted) text-xs">Read in:</span>
<!-- English (original) -->
<a
href={langUrl('')}
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
EN
</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if !data.isPro}
<!-- Locked for free users -->
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<!-- Spinning indicator while translating -->
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{label}
</span>
{:else if currentLang() === code}
<!-- Active translated lang -->
<a
href={langUrl(code)}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
>{label}</a>
{:else}
<!-- Inactive lang: click to request/navigate -->
<button
onclick={() => requestTranslation(code)}
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>{label}</button>
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-40 mx-0.5">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min
</p>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
{/if}
<!-- Language switcher (inline, compact) -->
{#if !data.isPreview}
<div class="flex items-center gap-1">
<a
href={langUrl('')}
class="px-2 py-0.5 rounded text-xs font-medium transition-colors
{currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>EN</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if !data.isPro}
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)/50 cursor-pointer"
>
<svg class="w-2.5 h-2.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<span class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{label}
</span>
{:else if currentLang() === code}
<a href={langUrl(code)} class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)">{label}</a>
{:else}
<button
type="button"
onclick={() => requestTranslation(code)}
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>{label}</button>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-0.5">Pro</a>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<!-- Audio player (hidden in focus mode) -->
<!-- ── Audio section (collapsible, hidden in focus/preview mode) ──────────── -->
{#if !data.isPreview && !layout.focusMode}
{#if !page.data.user}
<!-- Unauthenticated: sign-in prompt -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
{#if !page.data.user}
<!-- Unauthenticated -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
</div>
<a href="/login" class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
{m.nav_sign_in()}
</a>
</div>
<a
href="/login"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
{m.nav_sign_in()}
</a>
</div>
{:else if audioProRequired}
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
{:else if audioProRequired}
<div class="mb-6 px-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Upgrade to Pro for unlimited audio.</p>
</div>
<a href="/profile" class="shrink-0 px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
Upgrade
</a>
</div>
<a
href="/profile"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Upgrade
</a>
{:else}
<!-- Collapsible audio panel -->
<div class="mb-6">
<button
type="button"
onclick={() => { audioExpanded = !audioExpanded; }}
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) transition-colors hover:border-(--color-brand)/40
{audioExpanded ? 'rounded-t-lg' : 'rounded-lg'}"
>
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a8 8 0 010-12.728M8.464 15.536a5 5 0 010-7.072" />
</svg>
<span class="font-medium">Listen to this chapter</span>
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying}
<span class="text-xs text-(--color-brand)">● Playing</span>
{/if}
</span>
<svg class="w-4 h-4 text-(--color-muted) transition-transform duration-200 {audioExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if audioExpanded}
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
</div>
{/if}
</div>
{/if}
{:else if data.isPreview}
<div class="mb-6 px-4 py-2 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
{:else}
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
</div>
{/if}
<!-- Chapter content -->
<!-- ── Chapter content ───────────────────────────────────────────────────── -->
{#if fetchingContent}
<div class="flex flex-col items-center gap-3 py-16 text-(--color-muted) text-sm">
<svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -465,7 +517,7 @@
<p>{fetchError || m.reader_audio_error()}</p>
</div>
{:else if layout.readMode === 'paginated'}
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
<!-- ── Paginated reader ───────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
@@ -496,11 +548,9 @@
</svg>
Prev
</button>
<span class="text-sm text-(--color-muted) tabular-nums">
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</span>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
@@ -513,50 +563,59 @@
</svg>
</button>
</div>
<!-- Tap hint -->
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
{:else}
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
{@html html}
</div>
{/if}
<!-- Bottom nav + comments (hidden in focus mode) -->
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
{#if !layout.focusMode}
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_prev_chapter()}
</a>
{:else}
<span></span>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_next_chapter()} &rarr;
</a>
{/if}
</div>
<div class="mt-14 pt-6 border-t border-(--color-border)">
<!-- Next chapter: prominent full-width CTA -->
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="group flex items-center justify-between px-5 py-4 rounded-xl bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-colors"
>
<div>
<p class="text-xs text-(--color-muted) mb-0.5">{m.reader_next_chapter()}</p>
<p class="text-(--color-text) font-semibold group-hover:text-(--color-brand) transition-colors">
{m.reader_chapter_n({ n: String(data.next) })}
</p>
</div>
<svg class="w-5 h-5 text-(--color-muted) group-hover:text-(--color-brand) group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
{/if}
<!-- Previous chapter: small secondary link -->
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="mt-3 inline-flex items-center gap-1 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{m.reader_prev_chapter()}
</a>
{/if}
</div>
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>
{/if}
<!-- Focus mode floating nav (shown only in focus mode) -->
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
{#if layout.focusMode}
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
{#if data.prev}
@@ -595,22 +654,8 @@
</div>
{/if}
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
{#if settingsCtx}
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
<button
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
>
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<!-- Bottom sheet -->
{#if settingsPanelOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -648,12 +693,11 @@
{#if settingsTab === 'reading'}
<!-- ── Typography group ──────────────────────────────────────── -->
<!-- ── Typography ──────────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Font -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
<div class="flex gap-1.5 flex-1">
@@ -670,7 +714,6 @@
</div>
</div>
<!-- Size -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
<div class="flex gap-1.5 flex-1">
@@ -690,12 +733,11 @@
</div>
</div>
<!-- ── Layout group ──────────────────────────────────────────── -->
<!-- ── Layout ──────────────────────────────────────────────────── -->
<div class="mt-4 mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Read mode -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
<div class="flex gap-1.5 flex-1">
@@ -713,7 +755,6 @@
</div>
</div>
<!-- Line spacing -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">
@@ -731,7 +772,6 @@
</div>
</div>
<!-- Width -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
<div class="flex gap-1.5 flex-1">
@@ -749,7 +789,6 @@
</div>
</div>
<!-- Paragraphs -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
<div class="flex gap-1.5 flex-1">
@@ -767,7 +806,6 @@
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
@@ -784,12 +822,11 @@
{:else}
<!-- ── Listening tab ──────────────────────────────────────────── -->
<!-- ── Listening tab ──────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Player style -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">