Compare commits

...

8 Commits

Author SHA1 Message Date
root
e6f7f7297d feat: add sticky sidebar to chapter reader with ToC, progress, book info, and chapter nav
Some checks failed
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m2s
Release / Docker (push) Failing after 7m3s
Release / Gitea Release (push) Has been skipped
2026-04-12 22:44:24 +05:00
root
93cc0b6eb0 perf: fix discover page 4s load — parallel fetches + per-user caching
Three compounding issues caused the 4+ second load:

1. getAllRatings() ran sequentially after the first Promise.all group,
   adding it unnecessarily to the critical path. Now runs in parallel
   with listBooks/getVotedSlugs/getSavedSlugs (all 4 concurrent).

2. discovery_votes was fetched twice on every page load — once inside
   getBooksForDiscovery (via getVotedSlugs) and again by getVotedBooks.
   Fixed by caching getVotedSlugs results with a 30s TTL so the second
   call hits cache instead of PocketBase.

3. getVotedSlugs and getSavedSlugs were always uncached, hitting
   PocketBase on every navigation. Added short-TTL per-user Valkey
   cache entries (voted: 30s, saved: 60s). Cache is invalidated
   immediately after each write (upsertDiscoveryVote, clearDiscoveryVotes,
   undoDiscoveryVote, saveBook) so stale data is never served.
2026-04-12 22:34:46 +05:00
root
6af5a4966f fix: remove redundant X icons from SearchModal search input
Removed the custom clear button (shown when query is non-empty) and
suppressed the browser-native webkit search cancel button via CSS.
Only the single Cancel button remains, avoiding the double/triple X
clutter on wider screens.
2026-04-12 22:24:58 +05:00
root
14388e8186 fix: persist chapter-names results into job payload from sync SSE handler
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 5m35s
Release / Gitea Release (push) Successful in 23s
The SSE (non-async) chapter-names handler streamed results to the client
but never wrote them into the PocketBase job payload — only the initial
{pattern} stub was stored. The Review button then fetched the job and
found no results, showing 'No results found in this job's payload.'

Fix: accumulate allResults across batches (same as the async handler) and
write the full {pattern, slug, results:[...]} payload when marking done.
2026-04-12 18:44:09 +05:00
root
5cebbb1692 fix: restore pointer-events on ListeningMode and ChapterPickerOverlay
The wrapper div in +layout.svelte had pointer-events:none which blocked
all taps inside ListeningMode (chapter rows, buttons, scrolling). Removed
the wrapper div and moved the fly transition onto ListeningMode's own root
element so the slide-in animation works without stealing pointer events.
2026-04-12 18:31:50 +05:00
root
a0e705beec feat: redesign notifications settings with per-category in-app/push table
All checks were successful
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 5m49s
Release / Gitea Release (push) Successful in 21s
- Add notify_new_chapters_push field to AppUser, PATCH /api/profile, and profile loader
- Fix bell panel to reload notifications on every open (not just once on mount)
- Replace flat in-app + push toggles with structured category table (Category | In-app | Push)
- Add browser push master subscribe/unsubscribe row above the table
- Push column toggle disabled until browser is subscribed; shows — when unsupported/denied
- Update Notifications row hint to summarise active channels (In-app · Push / Off)
2026-04-12 17:56:53 +05:00
root
761ca83da5 fix: add push_subscriptions collection and notify_new_chapters migration to pb-init-v3.sh 2026-04-12 17:49:23 +05:00
root
48d0ae63bf feat: unified chapter picker overlay + currently reading quick-switch modal on reader
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m40s
Release / Gitea Release (push) Successful in 21s
2026-04-12 17:42:45 +05:00
12 changed files with 582 additions and 98 deletions

View File

@@ -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,
})
}

View File

@@ -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" \

View File

@@ -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)'

View 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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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. */
@@ -1582,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}`, {
@@ -2147,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(
@@ -2175,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> {
@@ -2185,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 ──────────────────────────────────────────────────────────────────
@@ -2279,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));
@@ -2301,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 };
@@ -2380,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 ────────────────────────────────────────────────────────────────

View File

@@ -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 -->

View File

@@ -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');

View File

@@ -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;
@@ -462,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">
@@ -517,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>
@@ -848,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}
@@ -1211,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>
@@ -1315,3 +1512,11 @@
</div>
{/if}
{/if}
<!-- Currently Reading modal -->
{#if readingModalOpen}
<CurrentlyReadingModal
currentSlug={data.book.slug}
onclose={() => (readingModalOpen = false)}
/>
{/if}

View File

@@ -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: [],

View File

@@ -236,8 +236,10 @@
let pushError = $state('');
// ── In-app notifications ──────────────────────────────────────────────────────
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
let notifyNewChaptersSaving = $state(false);
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
let notifyNewChaptersPush = $state(data.notifyNewChaptersPush ?? true);
let notifyNewChaptersSaving = $state(false);
let notifyNewChaptersPushSaving = $state(false);
async function toggleNotifyNewChapters() {
notifyNewChaptersSaving = true;
@@ -248,14 +250,27 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_new_chapters: next })
});
if (res.ok) {
notifyNewChapters = next;
}
if (res.ok) notifyNewChapters = next;
} catch { /* ignore */ } finally {
notifyNewChaptersSaving = false;
}
}
async function toggleNotifyNewChaptersPush() {
notifyNewChaptersPushSaving = true;
const next = !notifyNewChaptersPush;
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_new_chapters_push: next })
});
if (res.ok) notifyNewChaptersPush = next;
} catch { /* ignore */ } finally {
notifyNewChaptersPushSaving = false;
}
}
$effect(() => {
if (!browser) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
@@ -802,8 +817,16 @@
</svg>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text)">Notifications</span>
<span class="text-xs mr-2 hidden sm:inline {notifyNewChapters ? 'text-(--color-brand)' : 'text-(--color-muted)'}">
{notifyNewChapters ? 'On' : 'Off'}
<span class="text-xs mr-2 hidden sm:inline text-(--color-muted)">
{#if notifyNewChapters && pushState === 'subscribed'}
<span class="text-(--color-brand)">In-app · Push</span>
{:else if notifyNewChapters}
<span class="text-(--color-brand)">In-app</span>
{:else if pushState === 'subscribed'}
<span class="text-(--color-brand)">Push</span>
{:else}
Off
{/if}
</span>
<svg class={cn(chevronClass, expanded === 'notifications' ? 'rotate-90' : '')} 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"/>
@@ -814,50 +837,24 @@
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Notifications</span>
<!-- In-app -->
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-(--color-text)">In-app notifications</p>
<p class="text-sm text-(--color-muted) mt-0.5">
{#if notifyNewChapters}
Notified when new chapters arrive in your library.
{:else}
In-app new-chapter notifications are disabled.
{/if}
</p>
</div>
<button
type="button"
onclick={toggleNotifyNewChapters}
disabled={notifyNewChaptersSaving}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
)}
role="switch"
aria-checked={notifyNewChapters}
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Push -->
<!-- Browser push master toggle -->
{#if pushState !== 'unsupported'}
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-(--color-text)">Push notifications</p>
<p class="text-sm text-(--color-muted) mt-0.5">
<p class="text-sm font-medium text-(--color-text)">Browser push</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{#if pushState === 'subscribed'}
Push enabled for new chapters in your library.
This browser is subscribed to push notifications.
{:else if pushState === 'denied'}
Blocked by your browser. Change in browser settings.
Blocked by your browser — change in browser settings.
{:else if pushState === 'loading'}
Updating…
{:else}
Get notified when new chapters arrive.
Subscribe to receive push notifications in this browser.
{/if}
</p>
{#if pushError}
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
<p class="text-xs text-(--color-danger) mt-1">{pushError}</p>
{/if}
</div>
<div class="shrink-0">
@@ -890,6 +887,63 @@
</div>
</div>
{/if}
<!-- Per-category table -->
<div class="rounded-lg border border-(--color-border) overflow-hidden">
<!-- Header -->
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-2 bg-(--color-surface-3)/60 border-b border-(--color-border)">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Category</span>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">In-app</span>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">Push</span>
</div>
<!-- New chapters row -->
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-3">
<div>
<p class="text-sm font-medium text-(--color-text)">New chapters</p>
<p class="text-xs text-(--color-muted) mt-0.5">When a book in your library gets new chapters</p>
</div>
<!-- In-app toggle -->
<div class="w-12 flex justify-center">
<button
type="button"
role="switch"
aria-checked={notifyNewChapters}
aria-label="In-app notifications for new chapters"
onclick={toggleNotifyNewChapters}
disabled={notifyNewChaptersSaving}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) disabled:opacity-50',
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Push toggle -->
<div class="w-12 flex justify-center">
{#if pushState === 'unsupported'}
<span class="text-xs text-(--color-muted)" title="Push not supported in this browser"></span>
{:else if pushState === 'denied'}
<span class="text-xs text-(--color-muted)" title="Push blocked by browser"></span>
{:else}
<button
type="button"
role="switch"
aria-checked={notifyNewChaptersPush && pushState === 'subscribed'}
aria-label="Push notifications for new chapters"
onclick={toggleNotifyNewChaptersPush}
disabled={notifyNewChaptersPushSaving || pushState !== 'subscribed'}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) disabled:opacity-40',
notifyNewChaptersPush && pushState === 'subscribed' ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChaptersPush && pushState === 'subscribed' ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}