Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f7f7297d | ||
|
|
93cc0b6eb0 | ||
|
|
6af5a4966f | ||
|
|
14388e8186 | ||
|
|
5cebbb1692 | ||
|
|
a0e705beec | ||
|
|
761ca83da5 | ||
|
|
48d0ae63bf | ||
|
|
44f81bbf5c | ||
|
|
a2ce907480 | ||
|
|
e4631e7486 |
@@ -233,6 +233,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
var allResults []proposedChapterTitle
|
||||
chaptersDone := resumeFrom
|
||||
firstEvent := true
|
||||
for i, batch := range batches {
|
||||
@@ -287,6 +288,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
allResults = append(allResults, result...)
|
||||
chaptersDone += len(batch)
|
||||
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
@@ -310,16 +312,20 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
sseWrite(evt)
|
||||
}
|
||||
|
||||
// Mark job as done in PB.
|
||||
// Mark job as done in PB, persisting results so the Review button works.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
resultsJSON, _ := json.Marshal(allResults)
|
||||
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
|
||||
req.Pattern, req.Slug, string(resultsJSON))
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
"payload": finalPayload,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,14 @@ create "notifications" '{
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "push_subscriptions" '{
|
||||
"name":"push_subscriptions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"endpoint", "type":"text","required":true},
|
||||
{"name":"p256dh", "type":"text","required":true},
|
||||
{"name":"auth", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
@@ -393,6 +401,7 @@ add_field "user_settings" "font_size" "number"
|
||||
add_field "user_settings" "announce_chapter" "bool"
|
||||
add_field "user_settings" "audio_mode" "text"
|
||||
add_field "books" "archived" "bool"
|
||||
add_field "app_users" "notify_new_chapters" "bool"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close / chevron-down button is tapped. */
|
||||
/** Called when the close (✕) button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,6 @@
|
||||
)
|
||||
);
|
||||
|
||||
/** Scroll the active chapter into view instantly (no animation) when the
|
||||
* list is first rendered so the user never has to hunt for their position. */
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
@@ -73,9 +67,9 @@
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- chevron-down -->
|
||||
<!-- close / ✕ -->
|
||||
<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"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
@@ -105,7 +99,6 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
use:scrollIfActive={ch.number === activeChapter}
|
||||
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 === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
|
||||
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Book {
|
||||
slug: string;
|
||||
title: string;
|
||||
cover?: string;
|
||||
author?: string;
|
||||
genres?: string[] | string;
|
||||
}
|
||||
|
||||
interface ReadingEntry {
|
||||
book: Book;
|
||||
chapter: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The slug of the book currently being read — highlighted in the list. */
|
||||
currentSlug: string;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { currentSlug, onclose }: Props = $props();
|
||||
|
||||
let entries = $state<ReadingEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// ── Fetch in-progress books on mount ──────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/home');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { continue_reading: ReadingEntry[] };
|
||||
entries = data.continue_reading ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load your reading list.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Body scroll lock ──────────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
});
|
||||
|
||||
// ── Keyboard: Escape closes ───────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
// ── Navigate to a book's current chapter ─────────────────────────────────
|
||||
function openBook(entry: ReadingEntry) {
|
||||
onclose();
|
||||
goto(`/books/${entry.book.slug}/chapters/${entry.chapter}`);
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[70] flex flex-col"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<!-- Panel — matches SearchModal style -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
style="max-height: 100svh;"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<svg class="w-5 h-5 text-(--color-muted) shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<span class="flex-1 text-base font-semibold text-(--color-text)">Currently Reading</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="px-4 pt-3 pb-4 space-y-1">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="flex items-center gap-3 px-0 py-3 border-b border-(--color-border)/40">
|
||||
<div class="shrink-0 w-10 h-14 rounded bg-(--color-surface-3) animate-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-(--color-surface-3) rounded animate-pulse w-3/4"></div>
|
||||
<div class="h-3 bg-(--color-surface-3) rounded animate-pulse w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
|
||||
|
||||
{:else if entries.length === 0}
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-(--color-text) mb-1">No books in progress</p>
|
||||
<p class="text-xs text-(--color-muted)">Books you start reading will appear here.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each entries as entry, i}
|
||||
{@const genres = parseGenres(entry.book.genres)}
|
||||
{@const isCurrent = entry.book.slug === currentSlug}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openBook(entry)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
|
||||
isCurrent ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border) relative">
|
||||
{#if entry.book.cover}
|
||||
<img src={entry.book.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 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-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class={cn(
|
||||
'text-sm font-semibold leading-snug line-clamp-1 flex-1',
|
||||
isCurrent ? 'text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>
|
||||
{entry.book.title}
|
||||
</p>
|
||||
{#if isCurrent}
|
||||
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none mt-0.5">
|
||||
Now
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.book.author}
|
||||
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{entry.book.author}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span class="text-xs text-(--color-muted)/60">Ch. {entry.chapter}</span>
|
||||
{#each genres.slice(0, 2) as g}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron (dimmed for current, normal for others) -->
|
||||
<svg class={cn('w-4 h-4 shrink-0', isCurrent ? 'text-(--color-brand)/40' : 'text-(--color-muted)/40')} 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>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
@@ -98,7 +99,6 @@
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
<!-- Full-screen listening mode overlay -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
transition:fly={{ y: '100%', duration: 320, opacity: 1 }}
|
||||
bind:this={overlayEl}
|
||||
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
|
||||
style="
|
||||
@@ -434,17 +435,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
|
||||
|
||||
@@ -670,3 +660,16 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter picker rendered OUTSIDE the transformed overlay so that
|
||||
fixed inset-0 anchors to the real viewport, not the CSS-transformed
|
||||
containing block (transform: translateY breaks fixed positioning). -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -223,26 +223,13 @@
|
||||
bind:value={query}
|
||||
type="search"
|
||||
placeholder="Search books, authors, genres…"
|
||||
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
|
||||
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitQuery(); } }}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
/>
|
||||
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { query = ''; inputEl?.focus(); }}
|
||||
class="shrink-0 p-1 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface User {
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -658,11 +659,16 @@ function libraryFilter(sessionId: string, userId?: string): string {
|
||||
|
||||
/** Returns all slugs the user has explicitly saved to their library. */
|
||||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<UserLibraryEntry>(
|
||||
'user_library',
|
||||
libraryFilter(sessionId, userId)
|
||||
);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Returns whether a specific slug is saved. */
|
||||
@@ -709,7 +715,11 @@ export async function saveBook(
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||||
return;
|
||||
}
|
||||
// Invalidate saved-slugs cache so the next discover load excludes this book.
|
||||
const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
await cache.invalidate(savedKey);
|
||||
}
|
||||
|
||||
/** Remove a book from the user's library. */
|
||||
@@ -1219,6 +1229,47 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
|
||||
return entries.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of chapter number → best available voice for a given slug.
|
||||
* "Best" means: prefer `preferredVoice` if a done job exists for it,
|
||||
* otherwise fall back to any done voice for that chapter.
|
||||
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
|
||||
*/
|
||||
export async function getReadyChaptersForSlug(
|
||||
slug: string,
|
||||
preferredVoice = ''
|
||||
): Promise<Map<number, string>> {
|
||||
const cacheKey = `audio:ready_chapters:${slug}`;
|
||||
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
|
||||
const raw = cached ?? await (async () => {
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
|
||||
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
|
||||
chapter: j.chapter,
|
||||
voice: j.voice ?? ''
|
||||
}));
|
||||
await cache.set(cacheKey, result, 60);
|
||||
return result;
|
||||
})();
|
||||
|
||||
// Build chapter → voices map
|
||||
const byChapter = new Map<number, string[]>();
|
||||
for (const { chapter, voice } of raw) {
|
||||
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
|
||||
byChapter.get(chapter)!.push(voice);
|
||||
}
|
||||
|
||||
// Resolve best voice per chapter
|
||||
const result = new Map<number, string>();
|
||||
for (const [chapter, voices] of byChapter) {
|
||||
const best = preferredVoice && voices.includes(preferredVoice)
|
||||
? preferredVoice
|
||||
: voices[0] ?? '';
|
||||
result.set(chapter, best);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
@@ -1541,7 +1592,10 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
*/
|
||||
export async function updateUserNotificationPrefs(
|
||||
userId: string,
|
||||
prefs: { notify_new_chapters?: boolean }
|
||||
prefs: {
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||
@@ -2106,12 +2160,27 @@ function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */
|
||||
const VOTED_SLUGS_TTL = 30;
|
||||
const SAVED_SLUGS_TTL = 60;
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Invalidate the voted-slugs cache entry after a vote is recorded. */
|
||||
async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise<void> {
|
||||
const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
await cache.invalidate(key);
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
@@ -2134,6 +2203,7 @@ export async function upsertDiscoveryVote(
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
@@ -2144,6 +2214,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
@@ -2238,10 +2309,13 @@ export async function getBooksForDiscovery(
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
// Fetch all 4 independent data sources in parallel — previously getAllRatings
|
||||
// ran sequentially after the first group, adding it to the critical path.
|
||||
const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
getSavedSlugs(sessionId, userId),
|
||||
getAllRatings(),
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
@@ -2260,10 +2334,7 @@ export async function getBooksForDiscovery(
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
// Build slug→avg rating map
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
@@ -2339,6 +2410,7 @@ export async function undoDiscoveryVote(
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
} catch (e) { console.error('clear notifications:', e); }
|
||||
}
|
||||
$effect(() => { if (data.user) loadNotifications(); });
|
||||
$effect(() => { if (notificationsOpen && data.user) loadNotifications(); });
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
// Close search on navigation
|
||||
@@ -1136,12 +1137,10 @@
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
</div>
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
|
||||
|
||||
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug') ?? '';
|
||||
if (!slug) error(400, 'slug is required');
|
||||
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
const readyMap = await getReadyChaptersForSlug(slug, voice);
|
||||
|
||||
// Return array of { chapter, voice } pairs
|
||||
const chapters = [...readyMap.entries()].map(([chapter, v]) => ({ chapter, voice: v }));
|
||||
|
||||
return json({ chapters }, {
|
||||
headers: { 'Cache-Control': 'public, max-age=60' }
|
||||
});
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { log } from '$lib/server/logger';
|
||||
* PATCH /api/profile
|
||||
*
|
||||
* Update mutable profile preferences (currently: notification preferences).
|
||||
* Body: { notify_new_chapters?: boolean }
|
||||
* Body: { notify_new_chapters?: boolean, notify_new_chapters_push?: boolean }
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
@@ -19,10 +19,13 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
error(400, 'Invalid JSON');
|
||||
}
|
||||
|
||||
const prefs: { notify_new_chapters?: boolean } = {};
|
||||
const prefs: { notify_new_chapters?: boolean; notify_new_chapters_push?: boolean } = {};
|
||||
if (typeof body.notify_new_chapters === 'boolean') {
|
||||
prefs.notify_new_chapters = body.notify_new_chapters;
|
||||
}
|
||||
if (typeof body.notify_new_chapters_push === 'boolean') {
|
||||
prefs.notify_new_chapters_push = body.notify_new_chapters_push;
|
||||
}
|
||||
|
||||
if (Object.keys(prefs).length === 0) {
|
||||
error(400, 'No valid preferences provided');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getProgress, getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
@@ -13,20 +13,26 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
|
||||
let chapters, progress;
|
||||
let chapters, progress, readyMap;
|
||||
try {
|
||||
[chapters, progress] = await Promise.all([
|
||||
[chapters, progress, readyMap] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id)
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
|
||||
throw error(500, 'Failed to load chapters');
|
||||
}
|
||||
|
||||
// Serialize Map as plain object for SvelteKit data transfer
|
||||
const readyChapters: Record<number, string> = {};
|
||||
for (const [ch, voice] of readyMap) readyChapters[ch] = voice;
|
||||
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
|
||||
chapters,
|
||||
lastChapter: progress?.chapter ?? null
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
readyChapters
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
@@ -7,6 +9,18 @@
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// ── Ready-to-listen map ──────────────────────────────────────────────────
|
||||
// readyChapters is a Record<number, string> (chapter → voice) from server
|
||||
const readySet = $derived(new Set(Object.keys(data.readyChapters).map(Number)));
|
||||
const readyCount = $derived(readySet.size);
|
||||
|
||||
function listenChapter(chapterNum: number) {
|
||||
const voice = data.readyChapters[chapterNum];
|
||||
if (voice) audioStore.voice = voice;
|
||||
audioStore.autoStartChapter = chapterNum;
|
||||
goto(`/books/${data.book.slug}/chapters/${chapterNum}`);
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
|
||||
@@ -76,6 +90,20 @@
|
||||
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Audio ready banner ────────────────────────────────────────────────── -->
|
||||
{#if readyCount > 0}
|
||||
<div class="flex items-center gap-2.5 mb-4 px-3 py-2.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/25">
|
||||
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-(--color-brand) font-medium">
|
||||
{readyCount} chapter{readyCount !== 1 ? 's' : ''} ready to listen — tap
|
||||
<svg class="w-3 h-3 inline-block" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
to play instantly
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
@@ -147,40 +175,65 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
id="ch-{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
|
||||
{@const isReady = readySet.has(chapter.number)}
|
||||
<div
|
||||
class="flex items-center gap-1 rounded transition-colors group
|
||||
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
|
||||
>
|
||||
<!-- Number badge -->
|
||||
<span
|
||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
id="ch-{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 flex-1 min-w-0"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
|
||||
<!-- Title -->
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||
>
|
||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
{#if chapter.date_label}
|
||||
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
|
||||
{chapter.date_label}
|
||||
<!-- Number badge -->
|
||||
<span
|
||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||
<!-- Title -->
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||
>
|
||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||
</span>
|
||||
|
||||
<!-- Headphones icon for ready chapters -->
|
||||
{#if isReady}
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label="Audio ready">
|
||||
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
{#if chapter.date_label}
|
||||
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
|
||||
{chapter.date_label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Instant-play button (only for ready chapters) -->
|
||||
{#if isReady}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => listenChapter(chapter.number)}
|
||||
class="mr-2 flex items-center justify-center w-7 h-7 rounded-full bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) transition-colors shrink-0"
|
||||
title="Listen now"
|
||||
aria-label="Listen to chapter {chapter.number} now"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import type { Voice } from '$lib/types';
|
||||
@@ -87,18 +87,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
lang: '',
|
||||
translationStatus: 'unavailable' as string,
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl: null as string | null
|
||||
chapterImageUrl: null as string | null,
|
||||
audioReady: false,
|
||||
availableVoice: null as string | null
|
||||
};
|
||||
}
|
||||
|
||||
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
|
||||
// Fetch book metadata, chapter index, voice list, and chapter image check in parallel.
|
||||
// Fetch book metadata, chapter index, voice list, chapter image check, and
|
||||
// audio-ready map in parallel.
|
||||
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
|
||||
const [book, chapters, voicesRes, chapterImageRes] = await Promise.all([
|
||||
const [book, chapters, voicesRes, chapterImageRes, readyMap] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
backendFetch('/api/voices').catch(() => null),
|
||||
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null)
|
||||
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null),
|
||||
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
|
||||
]);
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
@@ -112,6 +116,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
|
||||
: null;
|
||||
|
||||
// Audio readiness for this specific chapter
|
||||
const availableVoice = readyMap.get(n) ?? null;
|
||||
const audioReady = availableVoice !== null;
|
||||
|
||||
// Parse voices — fall back to empty list on error
|
||||
let voices: Voice[] = [];
|
||||
try {
|
||||
@@ -146,7 +154,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
lang,
|
||||
translationStatus: 'done',
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl
|
||||
chapterImageUrl,
|
||||
audioReady,
|
||||
availableVoice
|
||||
};
|
||||
}
|
||||
// 404 = not generated yet — fall through to original, UI can trigger generation
|
||||
@@ -204,6 +214,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
lang: useTranslation ? lang : '',
|
||||
translationStatus,
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl
|
||||
chapterImageUrl,
|
||||
audioReady,
|
||||
availableVoice
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import CurrentlyReadingModal from '$lib/components/CurrentlyReadingModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -22,6 +23,9 @@
|
||||
let settingsPanelOpen = $state(false);
|
||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||
|
||||
// ── Currently reading modal ───────────────────────────────────────────────
|
||||
let readingModalOpen = $state(false);
|
||||
|
||||
const READER_FONTS = [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'serif', label: 'Serif' },
|
||||
@@ -64,9 +68,10 @@
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
pageLines: PageLines;
|
||||
showSidebar: boolean;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v2';
|
||||
const LAYOUT_KEY = 'reader_layout_v3';
|
||||
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%)' };
|
||||
/**
|
||||
@@ -75,7 +80,7 @@
|
||||
* 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' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal', showSidebar: true };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -427,6 +432,13 @@
|
||||
audioExpanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Audio-ready instant play ─────────────────────────────────────────────────
|
||||
function listenNow() {
|
||||
if (data.availableVoice) audioStore.voice = data.availableVoice;
|
||||
audioStore.autoStartChapter = data.chapter.number;
|
||||
audioExpanded = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -455,6 +467,12 @@
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Two-column grid wrapper (sidebar activates at xl when enabled) ──────── -->
|
||||
<div class="{layout.showSidebar && !layout.focusMode ? 'xl:grid xl:grid-cols-[1fr_18rem] xl:gap-10 xl:items-start' : ''}">
|
||||
|
||||
<!-- ── Main reading column ────────────────────────────────────────────────── -->
|
||||
<div>
|
||||
|
||||
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="flex items-center justify-between mb-8 gap-2">
|
||||
@@ -510,6 +528,18 @@
|
||||
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<!-- Book title — tappable, opens Currently Reading modal -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (readingModalOpen = true)}
|
||||
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-brand) text-sm font-medium transition-colors mb-1.5 max-w-full"
|
||||
title="Switch book"
|
||||
>
|
||||
<span class="truncate">{data.book.title}</span>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 opacity-60" 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>
|
||||
<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>
|
||||
@@ -600,6 +630,27 @@
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Audio ready prompt — shown when audio exists and player is not yet active -->
|
||||
{#if data.audioReady && !(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active)}
|
||||
<div class="mb-3 flex items-center gap-3 px-4 py-3 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30">
|
||||
<svg class="w-5 h-5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text)">Audio ready</p>
|
||||
<p class="text-xs text-(--color-muted)">This chapter has been narrated — listen instantly</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={listenNow}
|
||||
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Listen now
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Collapsible audio panel -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@@ -820,6 +871,169 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div><!-- end main column -->
|
||||
|
||||
<!-- ── Sidebar (xl+, hidden in focus mode, toggled via settings) ─────────── -->
|
||||
{#if layout.showSidebar && !layout.focusMode}
|
||||
<aside class="hidden xl:block">
|
||||
<div class="sticky top-24 flex flex-col gap-4">
|
||||
|
||||
<!-- Card 1: Book cover + info -->
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||
{#if data.book.cover}
|
||||
<a href="/books/{data.book.slug}" tabindex="-1" aria-hidden="true">
|
||||
<img
|
||||
src={data.book.cover}
|
||||
alt={data.book.title}
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="px-3 py-3 flex flex-col gap-2">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors leading-snug line-clamp-2"
|
||||
>
|
||||
{data.book.title}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span class="tabular-nums">Ch. {data.chapter.number}</span>
|
||||
{#if data.chapters.length > 0}
|
||||
<span class="opacity-40">·</span>
|
||||
<span class="tabular-nums">{data.chapters.length} chapters</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if wordCount > 0}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span class="tabular-nums">{wordCount.toLocaleString()} words</span>
|
||||
<span class="opacity-40">·</span>
|
||||
<span>~{Math.max(1, Math.round(wordCount / 200))} min</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Reading progress -->
|
||||
{#if data.chapters.length > 1}
|
||||
{@const progressPct = Math.round((data.chapter.number / data.chapters.length) * 100)}
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-4 py-3">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Progress</p>
|
||||
<div class="flex items-center justify-between text-xs text-(--color-muted) mb-1.5">
|
||||
<span>Chapter {data.chapter.number} of {data.chapters.length}</span>
|
||||
<span class="tabular-nums font-medium text-(--color-brand)">{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand) transition-all"
|
||||
style="width: {progressPct}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if layout.readMode === 'scroll' && scrollProgress > 0}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span>Page scroll</span>
|
||||
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||
style="width: {Math.round(scrollProgress * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="tabular-nums">{Math.round(scrollProgress * 100)}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if layout.readMode === 'paginated' && totalPages > 1}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span>Page</span>
|
||||
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||
style="width: {Math.round(((pageIndex + 1) / totalPages) * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="tabular-nums">{pageIndex + 1}/{totalPages}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card 3: Chapter ToC -->
|
||||
{#if data.chapters.length > 0}
|
||||
{@const tocChapters = data.chapters}
|
||||
{@const currentIdx = tocChapters.findIndex(c => c.number === data.chapter.number)}
|
||||
{@const windowStart = Math.max(0, currentIdx - 3)}
|
||||
{@const windowEnd = Math.min(tocChapters.length, windowStart + 10)}
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-2.5 border-b border-(--color-border)">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</p>
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-[10px] text-(--color-brand) hover:underline"
|
||||
>All {tocChapters.length}</a>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-(--color-border)/50 max-h-64 overflow-y-auto">
|
||||
{#each tocChapters.slice(windowStart, windowEnd) as ch}
|
||||
{@const isCurrent = ch.number === data.chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{ch.number}"
|
||||
class="flex items-start gap-2 px-3 py-2 text-xs transition-colors
|
||||
{isCurrent
|
||||
? 'bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums w-6 text-right opacity-60">{ch.number}</span>
|
||||
<span class="truncate leading-snug">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card 4: Chapter navigation -->
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-3 py-3 flex flex-col gap-2">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-0.5">Navigate</p>
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||
<span class="truncate">Chapter {data.prev}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||
First chapter
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-brand) bg-(--color-brand)/10 hover:bg-(--color-brand)/20 transition-colors font-medium"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||
<span class="truncate">Chapter {data.next}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||
Last chapter
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
</div><!-- end grid wrapper -->
|
||||
|
||||
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||
{@const atTop = scrollProgress <= 0.01}
|
||||
@@ -1183,6 +1397,17 @@
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('showSidebar', !layout.showSidebar)}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{layout.showSidebar ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={layout.showSidebar}
|
||||
>
|
||||
<span>Sidebar</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.showSidebar ? 'On — ToC, progress & nav' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1287,3 +1512,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Currently Reading modal -->
|
||||
{#if readingModalOpen}
|
||||
<CurrentlyReadingModal
|
||||
currentSlug={data.book.slug}
|
||||
onclose={() => (readingModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let q = $state(data.q);
|
||||
let sort = $state(data.sort);
|
||||
let q = $state(untrack(() => data.q));
|
||||
let sort = $state(untrack(() => data.sort));
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
@@ -35,8 +36,8 @@
|
||||
list = [...list].sort((a, b) => (a.book.title ?? '').localeCompare(b.book.title ?? ''));
|
||||
} else if (sort === 'recent') {
|
||||
list = [...list].sort((a, b) => {
|
||||
const da = a.book.updated ?? a.book.created ?? '';
|
||||
const db = b.book.updated ?? b.book.created ?? '';
|
||||
const da = a.book.meta_updated ?? '';
|
||||
const db = b.book.meta_updated ?? '';
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
email,
|
||||
polarCustomerId,
|
||||
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
|
||||
notifyNewChaptersPush: freshUser?.notify_new_chapters_push ?? true,
|
||||
stats: stats ?? {
|
||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user