feat(reader): voice modal with search, chapter search, comments collapse, audio stream fix
- 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)
This commit is contained in:
@@ -575,11 +575,14 @@
|
|||||||
|
|
||||||
// Slow path: audio not yet in MinIO.
|
// Slow path: audio not yet in MinIO.
|
||||||
//
|
//
|
||||||
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
|
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
|
||||||
// use the streaming endpoint — audio starts playing within seconds while
|
// starts playing within seconds. The stream handler checks MinIO first
|
||||||
// generation runs and MinIO is populated concurrently.
|
// (fast redirect if already cached) and otherwise generates + uploads
|
||||||
// Skip when enqueued=true to avoid double-generation with the async runner.
|
// concurrently. Even if the async runner is already working on this
|
||||||
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
|
// 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.
|
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||||
|
|||||||
@@ -241,311 +241,321 @@
|
|||||||
const totalCount = $derived(
|
const totalCount = $derived(
|
||||||
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<!-- Header + sort controls -->
|
{#if !expanded && !hasComments && !loading}
|
||||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
<!-- Collapsed: just a subtle link — no wasted real-estate for empty chapters -->
|
||||||
<h2 class="text-base font-semibold text-(--color-text)">
|
<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()}
|
{m.comments_heading()}
|
||||||
{#if !loading && totalCount > 0}
|
</button>
|
||||||
<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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-3">
|
<!-- Expanded: full comments section -->
|
||||||
{#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-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
<!-- Header + sort controls -->
|
||||||
<!-- Header -->
|
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<h2 class="text-base font-semibold text-(--color-text)">
|
||||||
{#if avatarUrls[comment.user_id]}
|
{m.comments_heading()}
|
||||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
{#if !loading && totalCount > 0}
|
||||||
{:else}
|
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
|
||||||
<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}
|
{/if}
|
||||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
</h2>
|
||||||
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
|
{#if !loading && comments.length > 0}
|
||||||
</div>
|
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
|
||||||
|
|
||||||
<!-- 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}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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)')}
|
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={() => {
|
onclick={() => (sort = 'top')}
|
||||||
if (replyingTo === comment.id) {
|
>{m.comments_top()}</Button>
|
||||||
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}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
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)')}
|
||||||
disabled={deleting}
|
onclick={() => (sort = 'new')}
|
||||||
onclick={() => deleteComment(comment.id)}
|
>{m.comments_new()}</Button>
|
||||||
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>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Inline reply form -->
|
<!-- Post form -->
|
||||||
{#if replyingTo === comment.id}
|
<div class="mb-6">
|
||||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
|
{#if isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={replyBody}
|
bind:value={newBody}
|
||||||
placeholder={m.comments_placeholder()}
|
placeholder={m.comments_placeholder()}
|
||||||
rows={2}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
||||||
{replyCharCount}/2000
|
{charCount}/2000
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
{#if replyError}
|
{#if postError}
|
||||||
<span class="text-xs text-(--color-danger)">{replyError}</span>
|
<span class="text-xs text-(--color-danger)">{postError}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-(--color-muted) hover:text-(--color-text)"
|
disabled={posting || !newBody.trim() || charOver}
|
||||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
onclick={postComment}
|
||||||
>{m.common_cancel()}</Button>
|
>
|
||||||
<Button
|
{posting ? m.comments_posting() : m.comments_submit()}
|
||||||
variant="default"
|
</Button>
|
||||||
size="sm"
|
|
||||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
|
||||||
onclick={() => postReply(comment.id)}
|
|
||||||
>
|
|
||||||
{replyPosting ? m.comments_posting() : m.comments_reply()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Comment list -->
|
||||||
{#if comment.replies && comment.replies.length > 0}
|
{#if loading}
|
||||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
|
<div class="flex flex-col gap-3">
|
||||||
{#each comment.replies as reply (reply.id)}
|
{#each Array(3) as _}
|
||||||
{@const replyVote = myVotes[reply.id]}
|
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
|
||||||
{@const replyVoting = votingIds.has(reply.id)}
|
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
|
||||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
|
||||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
<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' : ''}">
|
<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')}>
|
||||||
<!-- Reply header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{#if avatarUrls[reply.user_id]}
|
{#if avatarUrls[comment.user_id]}
|
||||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
|
<div class="w-6 h-6 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>
|
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.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">·</span>
|
|
||||||
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
|
|
||||||
</div>
|
</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">·</span>
|
||||||
|
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reply body -->
|
<!-- Body -->
|
||||||
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
|
||||||
<!-- Reply actions -->
|
<!-- Actions row: votes + reply + delete -->
|
||||||
<div class="flex items-center gap-3 pt-0.5">
|
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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)')}
|
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={replyVoting}
|
disabled={voting}
|
||||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
onclick={() => vote(comment.id, 'up')}
|
||||||
title={m.comments_vote_up()}
|
title={m.comments_vote_up()}
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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)')}
|
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={replyVoting}
|
disabled={voting}
|
||||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
onclick={() => vote(comment.id, 'down')}
|
||||||
title={m.comments_vote_down()}
|
title={m.comments_vote_down()}
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
|
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{#if replyIsOwner}
|
{#if isLoggedIn}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
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)')}
|
||||||
disabled={replyDeleting}
|
onclick={() => {
|
||||||
onclick={() => deleteComment(reply.id, comment.id)}
|
if (replyingTo === comment.id) {
|
||||||
title="Delete reply"
|
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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
{m.comments_delete()}
|
{m.comments_reply()}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
{#if isOwner}
|
||||||
{/each}
|
<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>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
<!-- Inline reply form -->
|
||||||
{/each}
|
{#if replyingTo === comment.id}
|
||||||
</div>
|
<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">·</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import type { Voice } from '$lib/types';
|
import type { Voice } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,10 +17,30 @@
|
|||||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
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 samplePlayingVoice = $state<string | null>(null);
|
||||||
let sampleAudio: HTMLAudioElement | 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 {
|
function voiceLabel(v: Voice | string): string {
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
const found = voices.find((x) => x.id === v);
|
const found = voices.find((x) => x.id === v);
|
||||||
@@ -60,7 +81,15 @@
|
|||||||
function selectVoice(voiceId: string) {
|
function selectVoice(voiceId: string) {
|
||||||
stopSample();
|
stopSample();
|
||||||
audioStore.voice = voiceId;
|
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 ────────────────────────────────────────────────────────────────
|
// ── Speed ────────────────────────────────────────────────────────────────
|
||||||
@@ -134,7 +163,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (showVoicePanel) { showVoicePanel = false; }
|
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||||
else { onclose(); }
|
else { onclose(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,10 +203,10 @@
|
|||||||
<!-- Voice selector button -->
|
<!-- Voice selector button -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (showVoicePanel = !showVoicePanel)}
|
onclick={() => (showVoiceModal = !showVoiceModal)}
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
'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-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||||
)}
|
)}
|
||||||
@@ -189,50 +218,108 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice panel (inline dropdown below header) -->
|
<!-- Voice modal (full-screen overlay) -->
|
||||||
{#if showVoicePanel && voices.length > 0}
|
{#if showVoiceModal && 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">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div
|
||||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Select Voice</p>
|
class="absolute inset-0 z-70 flex flex-col"
|
||||||
<button type="button" onclick={() => { stopSample(); showVoicePanel = false; }} class="text-(--color-muted) hover:text-(--color-text) transition-colors" aria-label="Close voice panel">
|
style="background: var(--color-surface);"
|
||||||
<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"/>
|
<!-- 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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Select Voice</span>
|
||||||
</div>
|
</div>
|
||||||
{#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]}
|
|
||||||
{#if group.length > 0}
|
<!-- Search input -->
|
||||||
<p class="text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0">{label}</p>
|
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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)}
|
{#each group as v (v.id)}
|
||||||
<div class="flex items-center rounded-lg border overflow-hidden text-xs
|
<div
|
||||||
{audioStore.voice === v.id
|
class={cn(
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10'
|
'flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors',
|
||||||
: 'border-(--color-border) bg-(--color-surface-3)'}">
|
audioStore.voice === v.id
|
||||||
|
? 'bg-(--color-brand)/8'
|
||||||
|
: 'hover:bg-(--color-surface-2)'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Select voice -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => selectVoice(v.id)}
|
onclick={() => selectVoice(v.id)}
|
||||||
class="px-2 py-1 font-medium transition-colors
|
class="flex-1 flex items-center gap-3 text-left"
|
||||||
{audioStore.voice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
>
|
||||||
>{voiceLabel(v)}</button>
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
onclick={() => playSample(v.id)}
|
||||||
class="px-1.5 py-1 border-l border-(--color-border) transition-colors
|
class={cn(
|
||||||
{samplePlayingVoice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
'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'}
|
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||||
|
aria-label={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||||
>
|
>
|
||||||
{#if samplePlayingVoice === v.id}
|
{#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}
|
{: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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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}
|
{/if}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -425,14 +512,32 @@
|
|||||||
<!-- Chapter list -->
|
<!-- Chapter list -->
|
||||||
{#if audioStore.chapters.length > 0}
|
{#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">
|
<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">
|
<div class="overflow-y-auto max-h-64">
|
||||||
{#each audioStore.chapters as ch (ch.number)}
|
{#each filteredChapters as ch (ch.number)}
|
||||||
<a
|
<button
|
||||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
type="button"
|
||||||
onclick={onclose}
|
onclick={() => playChapter(ch.number)}
|
||||||
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
|
class={cn(
|
||||||
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
|
'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="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
|
||||||
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||||
@@ -441,8 +546,11 @@
|
|||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</button>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user