Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70fed5cd7 | ||
|
|
5dd9dd2ebb | ||
|
|
1c5c25e5dd | ||
|
|
5177320418 | ||
|
|
836c9855af | ||
|
|
5c2c9b1b67 | ||
|
|
79b3de3e8d | ||
|
|
5804cd629a | ||
|
|
b130ba4e1b | ||
|
|
cc1f6b87e4 | ||
|
|
8279bd5caa | ||
|
|
59794e3694 | ||
|
|
150eb2a2af | ||
|
|
a0404cea57 |
@@ -189,6 +189,15 @@ html {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Hero carousel fade ─────────────────────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
@@ -98,6 +99,27 @@
|
||||
/** Currently active sample <audio> element — one at a time. */
|
||||
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// ── Chapter picker state ─────────────────────────────────────────────────
|
||||
let showChapterPanel = $state(false);
|
||||
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 playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
showChapterPanel = false;
|
||||
chapterSearch = '';
|
||||
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a voice.
|
||||
* Kokoro: "af_bella" → "Bella (US F)"
|
||||
@@ -256,11 +278,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Close voice panel when user clicks outside (escape key).
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
stopSample();
|
||||
showVoicePanel = false;
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +809,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -990,7 +1017,7 @@
|
||||
</button>
|
||||
<!-- Time display -->
|
||||
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
<!-- Speed cycle -->
|
||||
<button
|
||||
@@ -1024,21 +1051,29 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
class={cn('gap-1.5 text-xs', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title="Browse chapters"
|
||||
>
|
||||
<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 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
Chapters
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- Voice selector button -->
|
||||
{#if voices.length > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1119,6 +1154,79 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge (mirrors voice radio indicator) -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
<!-- ── This chapter is the active one ── -->
|
||||
|
||||
@@ -1170,7 +1278,7 @@
|
||||
<span>{m.reader_paused()}</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
onclose: () => void;
|
||||
/** When true, open the chapter picker immediately on mount. */
|
||||
openChapters?: boolean;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
let { onclose, openChapters = false }: Props = $props();
|
||||
|
||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||
const voices = $derived(audioStore.voices);
|
||||
@@ -18,6 +20,8 @@
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
let showVoiceModal = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showChapterModal = $state(openChapters && audioStore.chapters.length > 0);
|
||||
let voiceSearch = $state('');
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio: HTMLAudioElement | null = null;
|
||||
@@ -30,6 +34,14 @@
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
@@ -163,7 +175,8 @@
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
if (showChapterModal) { showChapterModal = false; }
|
||||
else if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
else { onclose(); }
|
||||
}
|
||||
}
|
||||
@@ -200,22 +213,43 @@
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Now Playing</span>
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
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',
|
||||
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)'
|
||||
)}
|
||||
>
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Chapters button -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showChapterModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-label="Browse chapters"
|
||||
>
|
||||
<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 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
Chapters
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
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)'
|
||||
)}
|
||||
>
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice modal (full-screen overlay) -->
|
||||
@@ -323,19 +357,93 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.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={() => { showChapterModal = false; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<!-- Search 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 chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge (mirrors voice radio indicator) -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === audioStore.chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body — fills remaining height, content spread vertically -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
|
||||
|
||||
<!-- Cover art + track info -->
|
||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
||||
<div class="flex flex-col items-center px-8 shrink-0">
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
|
||||
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
</svg>
|
||||
@@ -348,212 +456,171 @@
|
||||
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<div class="px-6 shrink-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||
<span>{formatTime(audioStore.currentTime)}</span>
|
||||
<span>{formatTime(audioStore.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom controls cluster: seek + transport + secondary -->
|
||||
<div class="flex flex-col gap-0 px-6 shrink-0">
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.speed = s)}
|
||||
class={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||
audioStore.speed === s
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title="Sleep timer"
|
||||
>
|
||||
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{sleepLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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}
|
||||
<!-- Seek bar -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||
<span>{formatTime(audioStore.currentTime)}</span>
|
||||
<span>{formatTime(audioStore.duration)}</span>
|
||||
</div>
|
||||
<div class="overflow-y-auto max-h-64">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
</div>
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
onclick={() => (audioStore.speed = s)}
|
||||
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)'
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||
audioStore.speed === s
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</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>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title="Sleep timer"
|
||||
>
|
||||
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{sleepLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -331,10 +331,46 @@ export async function invalidateBooksCache(): Promise<void> {
|
||||
cache.invalidate(BOOKS_CACHE_KEY),
|
||||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||
cache.invalidatePattern('books:recent:*'),
|
||||
cache.invalidatePattern('books:recently-updated:*')
|
||||
cache.invalidatePattern('books:recently-updated:*'),
|
||||
cache.invalidatePattern('books:trending:*'),
|
||||
cache.invalidatePattern('books:recs:*')
|
||||
]);
|
||||
}
|
||||
|
||||
/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */
|
||||
export async function getTrendingBooks(limit = 8): Promise<Book[]> {
|
||||
const key = `books:trending:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
|
||||
await cache.set(key, books, 15 * 60);
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Books matching the given genres that the user hasn't read yet.
|
||||
* The raw genre-query result is cached (shared across users); per-user slug
|
||||
* exclusion is applied in memory afterwards.
|
||||
*/
|
||||
export async function getRecommendedBooks(
|
||||
topGenres: string[],
|
||||
excludeSlugs: Set<string>,
|
||||
limit = 8
|
||||
): Promise<Book[]> {
|
||||
if (topGenres.length === 0) return [];
|
||||
const sortedGenres = [...topGenres].sort();
|
||||
const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
|
||||
let books = await cache.get<Book[]>(key);
|
||||
if (!books) {
|
||||
const genreFilter = sortedGenres
|
||||
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
|
||||
.join('||');
|
||||
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
|
||||
await cache.set(key, books, 10 * 60);
|
||||
}
|
||||
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
return listOne<Book>('books', `slug="${slug}"`);
|
||||
}
|
||||
@@ -1122,12 +1158,14 @@ export interface UserSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short device fingerprint from user-agent + IP.
|
||||
* SHA-256 of the concatenation, first 16 hex chars.
|
||||
* Generate a short device fingerprint from the user-agent alone.
|
||||
* IP is intentionally excluded so that network changes (VPN, mobile data,
|
||||
* home vs. office wifi) don't create duplicate sessions for the same device.
|
||||
* SHA-256 of the user-agent string, first 16 hex chars.
|
||||
*/
|
||||
function deviceFingerprint(userAgent: string, ip: string): string {
|
||||
function deviceFingerprint(userAgent: string, _ip?: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${userAgent}::${ip}`)
|
||||
.update(userAgent)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
@@ -1154,12 +1192,13 @@ export async function upsertUserSession(
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Touch last_seen and return the existing authSessionId
|
||||
// Touch last_seen and update IP (may have changed due to network switch).
|
||||
// Return the existing authSessionId so no new session row is created.
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString(), ip })
|
||||
}).catch(() => {});
|
||||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '../app.css';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { setContext } from 'svelte';
|
||||
import { setContext, untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -33,28 +33,13 @@
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
let listeningModeChapters = $state(false);
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chapterDrawerOpen && activeChapterEl) {
|
||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||
@@ -171,15 +156,23 @@
|
||||
});
|
||||
|
||||
// Handle toggle requests from AudioPlayer controller.
|
||||
// IMPORTANT: isPlaying must be read inside untrack() so the effect only
|
||||
// re-runs when toggleRequest increments, not every time isPlaying changes.
|
||||
// Without untrack the effect subscribes to both toggleRequest AND isPlaying,
|
||||
// causing an infinite play/pause loop: play() fires onplay → isPlaying=true
|
||||
// → effect re-runs → sees isPlaying=true → calls pause() → onpause fires
|
||||
// → isPlaying=false → effect re-runs → calls play() → …
|
||||
$effect(() => {
|
||||
// Read toggleRequest to subscribe; ignore value 0 (initial).
|
||||
const _req = audioStore.toggleRequest;
|
||||
if (!audioEl || _req === 0) return;
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl.pause();
|
||||
} else {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
untrack(() => {
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl!.pause();
|
||||
} else {
|
||||
audioEl!.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle seek requests from AudioPlayer controller.
|
||||
@@ -268,6 +261,11 @@
|
||||
}, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -391,9 +389,12 @@
|
||||
</a>
|
||||
|
||||
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
|
||||
<a
|
||||
href="/books/{page.data.book.slug}"
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs transition-colors"
|
||||
>
|
||||
{page.data.book.title}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if data.user}
|
||||
@@ -720,7 +721,9 @@
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-(--color-border) mt-auto">
|
||||
<footer class="border-t border-(--color-border) mt-auto"
|
||||
class:hidden={/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
|
||||
<!-- Top row: site links -->
|
||||
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
@@ -787,52 +790,6 @@
|
||||
{#if audioStore.active}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Scrollable list -->
|
||||
<div class="overflow-y-auto px-4">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<div class="h-0.5 bg-(--color-surface-2)">
|
||||
@@ -859,10 +816,10 @@
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
||||
|
||||
<!-- Track info (click to open chapter list drawer) -->
|
||||
<!-- Track info (click to open chapter list in listening mode) -->
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
onclick={() => { if (audioStore.chapters.length > 0) { listeningModeChapters = true; listeningModeOpen = true; } }}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
@@ -872,7 +829,7 @@
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
{#if audioStore.isPreview}
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
|
||||
{/if}
|
||||
@@ -965,7 +922,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (listeningModeOpen = true)}
|
||||
onclick={() => { listeningModeChapters = false; listeningModeOpen = true; }}
|
||||
title="Listening mode"
|
||||
aria-label="Open listening mode"
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
@@ -991,9 +948,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening mode full-screen overlay -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -4,15 +4,35 @@ import {
|
||||
recentlyUpdatedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
getSubscriptionFeed,
|
||||
getTrendingBooks,
|
||||
getRecommendedBooks
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
function parseGenresLocal(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function computeStreak(progressList: Progress[]): number {
|
||||
const days = new Set(
|
||||
progressList.filter((p) => p.updated).map((p) => p.updated.slice(0, 10))
|
||||
);
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||||
else if (i > 0) break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Step 1: fetch progress + recent books + stats in parallel.
|
||||
// We intentionally do NOT call listBooks() here — we only need books that
|
||||
// appear in the user's progress list, which is a tiny subset of 15k books.
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
@@ -27,8 +47,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.error('home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
// Step 2: fetch only the books we actually need for continue-reading.
|
||||
// This is O(progress entries) instead of O(15k books).
|
||||
const streak = computeStreak(progressList);
|
||||
|
||||
// Fetch only the books we need for continue-reading (avoid loading all books)
|
||||
const progressSlugs = progressList.map((p) => p.slug);
|
||||
const progressBooks = progressSlugs.length > 0
|
||||
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
|
||||
@@ -36,31 +57,65 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
|
||||
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Continue reading: progress entries joined with book data, most recent first
|
||||
// All continue-reading entries joined with book data, most recent first
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
.slice(0, 6)
|
||||
.slice(0, 8)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
|
||||
// Recently updated: deduplicate against continueReading slugs
|
||||
// Split into in-progress vs completed
|
||||
const continueInProgress = continueReading.filter(
|
||||
({ book, chapter }) => book.total_chapters === 0 || chapter < book.total_chapters
|
||||
);
|
||||
const continueCompleted = continueReading.filter(
|
||||
({ book, chapter }) => book.total_chapters > 0 && chapter >= book.total_chapters
|
||||
);
|
||||
|
||||
// Top genres from books the user has been reading
|
||||
const genreFreq = new Map<string, number>();
|
||||
for (const { book } of continueReading) {
|
||||
for (const g of parseGenresLocal(book.genres)) {
|
||||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const topGenres = [...genreFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
|
||||
// Deduplicate recently-updated against in-progress slugs
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only when logged in
|
||||
const subscriptionFeed = locals.user
|
||||
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: [];
|
||||
// Fetch trending, recommendations, and subscription feed in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
|
||||
getTrendingBooks(8).catch(() => [] as Book[]),
|
||||
topGenres.length > 0
|
||||
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
||||
: Promise.resolve([] as Book[]),
|
||||
locals.user
|
||||
? getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
// Strip books the user is already reading from trending (redundant)
|
||||
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
continueInProgress,
|
||||
continueCompleted,
|
||||
recentlyUpdated,
|
||||
subscriptionFeed,
|
||||
trendingBooks: trendingFiltered,
|
||||
recommendedBooks,
|
||||
topGenre: topGenres[0] ?? null,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
booksInProgress: continueInProgress.length,
|
||||
streak
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
|
||||
// ── Section visibility ────────────────────────────────────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
|
||||
const SECTIONS_KEY = 'home_sections_v1';
|
||||
|
||||
const SECTION_LABELS: Record<SectionId, string> = {
|
||||
'recently-updated': 'Recently Updated',
|
||||
'browse-genre': 'Browse by Genre',
|
||||
'from-following': 'From Following',
|
||||
};
|
||||
|
||||
function loadHidden(): Set<SectionId> {
|
||||
if (!browser) return new Set();
|
||||
try {
|
||||
@@ -38,6 +34,14 @@
|
||||
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
|
||||
}
|
||||
|
||||
const SECTION_LABELS = $derived<Record<SectionId, string>>({
|
||||
'recently-updated': 'Recently Updated',
|
||||
'browse-genre': 'Browse by Genre',
|
||||
'from-following': 'From Following',
|
||||
'trending': 'Trending Now',
|
||||
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
||||
});
|
||||
|
||||
const hiddenList = $derived(
|
||||
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
|
||||
);
|
||||
@@ -51,8 +55,6 @@
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
|
||||
// counting how many times the same book appears (= new chapters added).
|
||||
const dedupedRecent = $derived.by(() => {
|
||||
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
|
||||
for (const book of data.recentlyUpdated) {
|
||||
@@ -70,63 +72,180 @@
|
||||
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||
];
|
||||
|
||||
// Hero = first continue-reading item; shelf = the rest
|
||||
const heroBook = $derived(data.continueReading[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueReading.slice(1));
|
||||
// ── Hero carousel ────────────────────────────────────────────────────────
|
||||
const heroBooks = $derived(data.continueInProgress);
|
||||
let heroIndex = $state(0);
|
||||
const heroBook = $derived(heroBooks[heroIndex] ?? null);
|
||||
// Shelf shows remaining books not in the hero
|
||||
const shelfBooks = $derived(
|
||||
heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : []
|
||||
);
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
|
||||
function heroPrev() {
|
||||
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroNext() {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroDot(i: number) {
|
||||
heroIndex = i;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
let autoAdvanceTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function resetAutoAdvance() {
|
||||
if (autoAdvanceTimer) clearInterval(autoAdvanceTimer);
|
||||
if (heroBooks.length > 1) {
|
||||
autoAdvanceTimer = setInterval(() => {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
}, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
resetAutoAdvance();
|
||||
return () => { if (autoAdvanceTimer) clearInterval(autoAdvanceTimer); };
|
||||
});
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
goto(`/books/${slug}/chapters/${chapter}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
<section class="mb-10">
|
||||
<a
|
||||
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
|
||||
{#if heroBook.book.cover}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<section class="mb-6">
|
||||
<div class="relative">
|
||||
<!-- Card -->
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 animate-fade-in" loading="eager" />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prev / Next arrow buttons (only when multiple books) -->
|
||||
{#if heroBooks.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroPrev}
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Previous book"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroNext}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Next book"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</span>
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-1.5 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
class="rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
|
||||
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
|
||||
{#if streak > 0}
|
||||
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-(--color-text)">{streak}</span>
|
||||
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
||||
</span>
|
||||
{#if data.stats.booksInProgress > 0}
|
||||
<span class="text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Continue Reading shelf ──────────────────────────────────────────────────── -->
|
||||
{#if shelfBooks.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
@@ -135,7 +254,56 @@
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<a href="/books/{book.slug}/chapters/{chapter}"
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<!-- Chapters ahead badge -->
|
||||
{#if book.total_chapters > 0 && chapter < book.total_chapters}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-black/60 text-white font-medium px-1.5 py-0.5 rounded">
|
||||
{book.total_chapters - chapter} left
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Listen button (hover overlay) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, chapter)}
|
||||
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Listen"
|
||||
aria-label="Listen to chapter {chapter}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Completed shelf ────────────────────────────────────────────────────────── -->
|
||||
{#if data.continueCompleted.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.continueCompleted as { book, chapter }}
|
||||
<a href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
@@ -145,12 +313,13 @@
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">✓ Done</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.total_chapters > 0}
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{chapter} chapters</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -184,6 +353,102 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Trending Now ───────────────────────────────────────────────────────────── -->
|
||||
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('trending')} title="Hide section"
|
||||
class="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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.trendingBooks as book}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Because you read [Genre] ──────────────────────────────────────────────── -->
|
||||
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
|
||||
</h2>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.recommendedBooks as book}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
|
||||
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
||||
<section class="mb-10">
|
||||
@@ -272,8 +537,8 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
|
||||
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
|
||||
<!-- ── Empty state ───────────────────────────────────────────────────────────── -->
|
||||
{#if data.continueInProgress.length === 0 && data.continueCompleted.length === 0 && dedupedRecent.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
@@ -301,8 +566,12 @@
|
||||
{/if}
|
||||
|
||||
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
|
||||
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
|
||||
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted) flex-wrap">
|
||||
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
|
||||
<span class="w-px h-4 bg-(--color-border)"></span>
|
||||
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
|
||||
{#if streak > 0}
|
||||
<span class="w-px h-4 bg-(--color-border)"></span>
|
||||
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak 🔥</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
33
ui/src/routes/api/library/bulk-remove/+server.ts
Normal file
33
ui/src/routes/api/library/bulk-remove/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { unsaveBook } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/library/bulk-remove
|
||||
* Body: { slugs: string[] }
|
||||
* Removes multiple books from the user's library at once.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const slugs: unknown = body?.slugs;
|
||||
if (!Array.isArray(slugs) || slugs.length === 0) {
|
||||
error(400, 'slugs must be a non-empty array');
|
||||
}
|
||||
const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string');
|
||||
if (validSlugs.length === 0) error(400, 'no valid slugs provided');
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
validSlugs.map((slug) => unsaveBook(locals.sessionId, slug, locals.user?.id))
|
||||
);
|
||||
|
||||
const failed = results
|
||||
.map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null))
|
||||
.filter(Boolean);
|
||||
|
||||
if (failed.length > 0) {
|
||||
log.error('library', 'bulk-remove partial failure', { failed });
|
||||
}
|
||||
|
||||
return json({ ok: true, removed: validSlugs.length - failed.length, failed });
|
||||
};
|
||||
44
ui/src/routes/api/library/bulk-shelf/+server.ts
Normal file
44
ui/src/routes/api/library/bulk-shelf/+server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { updateBookShelf } from '$lib/server/pocketbase';
|
||||
import type { ShelfName } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const VALID_SHELVES: ShelfName[] = ['', 'plan_to_read', 'completed', 'dropped'];
|
||||
|
||||
/**
|
||||
* POST /api/library/bulk-shelf
|
||||
* Body: { slugs: string[], shelf: ShelfName }
|
||||
* Moves multiple books to the given shelf at once.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const slugs: unknown = body?.slugs;
|
||||
const shelf: unknown = body?.shelf;
|
||||
|
||||
if (!Array.isArray(slugs) || slugs.length === 0) {
|
||||
error(400, 'slugs must be a non-empty array');
|
||||
}
|
||||
if (typeof shelf !== 'string' || !VALID_SHELVES.includes(shelf as ShelfName)) {
|
||||
error(400, 'invalid shelf value');
|
||||
}
|
||||
|
||||
const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string');
|
||||
if (validSlugs.length === 0) error(400, 'no valid slugs provided');
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
validSlugs.map((slug) =>
|
||||
updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id)
|
||||
)
|
||||
);
|
||||
|
||||
const failed = results
|
||||
.map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null))
|
||||
.filter(Boolean);
|
||||
|
||||
if (failed.length > 0) {
|
||||
log.error('library', 'bulk-shelf partial failure', { failed, shelf });
|
||||
}
|
||||
|
||||
return json({ ok: true, updated: validSlugs.length - failed.length, failed });
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -38,19 +39,143 @@
|
||||
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
|
||||
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
|
||||
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
|
||||
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
|
||||
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length
|
||||
});
|
||||
|
||||
// ── Selection / bulk-action state ─────────────────────────────────────────
|
||||
let selectMode = $state(false);
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let busy = $state(false);
|
||||
let shelfPickerOpen = $state(false);
|
||||
|
||||
const selectedCount = $derived(selected.size);
|
||||
const allVisibleSelected = $derived(
|
||||
filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug))
|
||||
);
|
||||
|
||||
function enterSelectMode(slug: string) {
|
||||
selectMode = true;
|
||||
selected = new Set([slug]);
|
||||
}
|
||||
|
||||
function exitSelectMode() {
|
||||
selectMode = false;
|
||||
selected = new Set();
|
||||
shelfPickerOpen = false;
|
||||
}
|
||||
|
||||
function toggleSelect(slug: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(slug)) next.delete(slug);
|
||||
else next.add(slug);
|
||||
selected = next;
|
||||
if (next.size === 0) exitSelectMode();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allVisibleSelected) {
|
||||
selected = new Set();
|
||||
exitSelectMode();
|
||||
} else {
|
||||
selected = new Set(filteredBooks.map((b) => b.slug));
|
||||
}
|
||||
}
|
||||
|
||||
// Long-press support (pointer events, works on desktop + mobile)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let longPressFired = false;
|
||||
|
||||
function onPointerDown(slug: string) {
|
||||
if (selectMode) return;
|
||||
longPressFired = false;
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressFired = true;
|
||||
enterSelectMode(slug);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent navigation click if long-press just fired
|
||||
function onCardClick(e: MouseEvent, slug: string) {
|
||||
if (selectMode) {
|
||||
e.preventDefault();
|
||||
toggleSelect(slug);
|
||||
return;
|
||||
}
|
||||
if (longPressFired) {
|
||||
e.preventDefault();
|
||||
longPressFired = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk actions ──────────────────────────────────────────────────────────
|
||||
async function bulkRemove() {
|
||||
if (busy || selected.size === 0) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/library/bulk-remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slugs: [...selected] })
|
||||
});
|
||||
await invalidateAll();
|
||||
} finally {
|
||||
busy = false;
|
||||
exitSelectMode();
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkMoveShelf(shelf: Shelf) {
|
||||
if (busy || selected.size === 0) return;
|
||||
busy = true;
|
||||
shelfPickerOpen = false;
|
||||
try {
|
||||
await fetch('/api/library/bulk-shelf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slugs: [...selected], shelf })
|
||||
});
|
||||
await invalidateAll();
|
||||
} finally {
|
||||
busy = false;
|
||||
exitSelectMode();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.books_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
|
||||
</p>
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
|
||||
</p>
|
||||
</div>
|
||||
{#if selectMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={exitSelectMode}
|
||||
class="text-sm text-(--color-muted) hover:text-(--color-text) transition-colors pt-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !data.books?.length}
|
||||
@@ -63,22 +188,34 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shelf tabs -->
|
||||
<div class="flex gap-1 flex-wrap mb-4">
|
||||
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
|
||||
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeShelf = shelf)}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
|
||||
{activeShelf === shelf
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- Shelf tabs + select-all row -->
|
||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||
{#if selectMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSelectAll}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
|
||||
bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)"
|
||||
>
|
||||
{allVisibleSelected ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
<span class="text-sm text-(--color-muted)">{selectedCount} selected</span>
|
||||
{:else}
|
||||
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
|
||||
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeShelf = shelf)}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
|
||||
{activeShelf === shelf
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@@ -86,18 +223,41 @@
|
||||
{@const lastChapter = data.progressMap[book.slug]}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
{@const bookShelf = shelfMap[book.slug] ?? ''}
|
||||
{@const isSelected = selected.has(book.slug)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
onclick={(e) => onCardClick(e, book.slug)}
|
||||
onpointerdown={() => onPointerDown(book.slug)}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
draggable="false"
|
||||
class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none
|
||||
{isSelected
|
||||
? 'border-(--color-brand) ring-2 ring-(--color-brand)/40'
|
||||
: 'border-(--color-border) hover:bg-(--color-surface-3) hover:border-zinc-500'}"
|
||||
>
|
||||
<!-- Selection overlay -->
|
||||
{#if selectMode}
|
||||
<div class="absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors
|
||||
{isSelected ? 'bg-(--color-brand) border-(--color-brand)' : 'bg-black/40 border-white/60'}">
|
||||
{#if isSelected}
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cover image -->
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 {selectMode ? 'pointer-events-none' : ''}"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
@@ -148,3 +308,73 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bulk action bar (sticky bottom, shown in selection mode) -->
|
||||
{#if selectMode}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) px-4 py-3 flex items-center gap-3 shadow-lg">
|
||||
<span class="text-sm text-(--color-muted) mr-auto">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
|
||||
<!-- Move to shelf picker -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selectedCount === 0}
|
||||
onclick={() => (shelfPickerOpen = !shelfPickerOpen)}
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
bg-(--color-surface-3) text-(--color-text) border border-(--color-border)
|
||||
hover:border-zinc-500 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
Move to shelf
|
||||
<svg class="w-3.5 h-3.5 transition-transform {shelfPickerOpen ? '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 shelfPickerOpen}
|
||||
<div class="absolute bottom-full mb-2 right-0 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden min-w-[160px]">
|
||||
{#each ([['', 'Reading'], ['plan_to_read', 'Plan to Read'], ['completed', 'Completed'], ['dropped', 'Dropped']] as const) as [val, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => bulkMoveShelf(val as Shelf)}
|
||||
class="w-full text-left px-4 py-2.5 text-sm text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selectedCount === 0}
|
||||
onclick={bulkRemove}
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
bg-red-500/10 text-red-400 border border-red-500/30
|
||||
hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{#if busy}
|
||||
<svg class="w-4 h-4 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>
|
||||
{:else}
|
||||
<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="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>
|
||||
{/if}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacer so last row of cards isn't hidden behind the action bar -->
|
||||
<div class="h-20"></div>
|
||||
{/if}
|
||||
|
||||
@@ -633,22 +633,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Summary with expand toggle -->
|
||||
{#if book.summary}
|
||||
<div class="mt-1">
|
||||
<p class="text-(--color-muted) text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
|
||||
{book.summary}
|
||||
</p>
|
||||
{#if book.summary.length > 220}
|
||||
<button
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
|
||||
>
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- CTA buttons — desktop only -->
|
||||
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
|
||||
@@ -825,6 +809,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Book description ──────────────────────────────────────────────────────── -->
|
||||
{#if book.summary}
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<p
|
||||
class="text-(--color-muted) text-sm leading-7 break-words whitespace-pre-line {summaryExpanded ? '' : 'line-clamp-5'}"
|
||||
>
|
||||
{book.summary}
|
||||
</p>
|
||||
{#if !summaryExpanded && book.summary.length > 300}
|
||||
<!-- gradient fade over the last line when collapsed -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-(--color-surface) to-transparent pointer-events-none"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if book.summary.length > 300}
|
||||
<button
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="mt-2 text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
<svg class="w-3 h-3 transition-transform {summaryExpanded ? '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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Download row ══ -->
|
||||
{#if data.inLib && chapterList.length > 0}
|
||||
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'compact';
|
||||
/** Controls how many lines fit on a page by adjusting the container height offset. */
|
||||
type PageLines = 'less' | 'normal' | 'more';
|
||||
|
||||
interface LayoutPrefs {
|
||||
readMode: ReadMode;
|
||||
@@ -61,12 +63,19 @@
|
||||
paraStyle: ParaStyle;
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
pageLines: PageLines;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v1';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
/**
|
||||
* Extra rem subtracted from (or added to) the paginated container height.
|
||||
* Normal (0rem) keeps the existing calc(); Less (-4rem) makes the container
|
||||
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
|
||||
*/
|
||||
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -114,8 +123,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html changes or container is bound
|
||||
// Re-run when html, container refs, or mini-player visibility changes
|
||||
// (mini-player adds pb-24 which reduces the available viewport height)
|
||||
void html; void paginatedContainerEl; void paginatedContentEl;
|
||||
void audioStore.active; void audioExpanded;
|
||||
requestAnimationFrame(() => {
|
||||
if (!paginatedContainerEl || !paginatedContentEl) return;
|
||||
const h = paginatedContainerEl.clientHeight;
|
||||
@@ -139,10 +150,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard nav for paginated mode
|
||||
// Keyboard nav for paginated mode + Escape to close settings overlay
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && settingsPanelOpen) {
|
||||
settingsPanelOpen = false;
|
||||
return;
|
||||
}
|
||||
if (layout.readMode !== 'paginated') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -287,6 +302,15 @@
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
|
||||
// Strip scraper artifacts from chapter titles:
|
||||
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
||||
// - Everything after the first newline (often includes a scraped date)
|
||||
const cleanTitle = $derived.by(() => {
|
||||
let t = (data.chapter.title || '').split('\n')[0].trim();
|
||||
t = t.replace(/^\d+(?=Chapter)/i, '').trim();
|
||||
return t || `Chapter ${data.chapter.number}`;
|
||||
});
|
||||
|
||||
// Audio panel: auto-open if this chapter is already loaded/playing in the store
|
||||
// svelte-ignore state_referenced_locally
|
||||
let audioExpanded = $state(
|
||||
@@ -301,7 +325,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
<title>{cleanTitle} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
||||
@@ -364,8 +388,11 @@
|
||||
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
|
||||
{m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</p>
|
||||
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
{cleanTitle}
|
||||
</h1>
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
|
||||
{#if wordCount > 0}
|
||||
@@ -375,6 +402,9 @@
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.chapter.date_label}
|
||||
<span class="text-(--color-muted) text-xs opacity-60">{data.chapter.date_label}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Language switcher (inline, compact) -->
|
||||
{#if !data.isPreview}
|
||||
@@ -483,7 +513,7 @@
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
@@ -523,7 +553,9 @@
|
||||
role="none"
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
style="height: {layout.focusMode
|
||||
? 'calc(100svh - 8rem)'
|
||||
: `calc(100svh - 26rem - ${PAGE_LINES_OFFSET[layout.pageLines]})`};"
|
||||
onclick={handlePaginatedClick}
|
||||
>
|
||||
<div
|
||||
@@ -617,65 +649,78 @@
|
||||
|
||||
<!-- ── 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}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50">
|
||||
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<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_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', false)}
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
|
||||
aria-label="Exit focus mode"
|
||||
>
|
||||
<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"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', false)}
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
|
||||
aria-label="Exit focus mode"
|
||||
>
|
||||
<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"/>
|
||||
</svg>
|
||||
Exit focus
|
||||
</button>
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||
>
|
||||
{m.reader_chapter_n({ n: String(data.next) })}
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
Exit focus
|
||||
</button>
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
{m.reader_chapter_n({ n: String(data.next) })}
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
|
||||
<!-- ── Reader settings overlay ───────────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
{#if settingsPanelOpen}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsPanelOpen = false)}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<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">Reader Settings</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
|
||||
<div class="flex gap-1 mx-4 mt-3 mb-1 p-1 rounded-xl bg-(--color-surface-2) shrink-0 border border-(--color-border)">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsTab = 'reading')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'reading'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Reading</button>
|
||||
<button
|
||||
@@ -683,13 +728,13 @@
|
||||
onclick={() => (settingsTab = 'listening')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'listening'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Listening</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-6 flex flex-col gap-0 mt-2">
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
@@ -755,6 +800,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if layout.readMode === 'paginated'}
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Lines</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['less', 'Few'], ['normal', 'Normal'], ['more', 'Many']] as const) as [v, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('pageLines', v)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.pageLines === v
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.pageLines === v}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
|
||||
@@ -591,7 +591,7 @@
|
||||
<button
|
||||
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
@@ -694,7 +694,7 @@
|
||||
<button
|
||||
onclick={() => scrapeNovel(novel)}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user