Compare commits

...

5 Commits

Author SHA1 Message Date
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
23 changed files with 1182 additions and 535 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

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

@@ -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,177 @@
{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">
<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; }}
/>
</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 +507,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 +538,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 +553,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 +644,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 +683,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 +704,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 +723,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 +745,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 +762,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 +779,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 +796,6 @@
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
@@ -784,12 +812,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">