Compare commits
20 Commits
v2.6.70
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e705beec | ||
|
|
761ca83da5 | ||
|
|
48d0ae63bf | ||
|
|
44f81bbf5c | ||
|
|
a2ce907480 | ||
|
|
e4631e7486 | ||
|
|
015cb8a0cd | ||
|
|
53edb6fdef | ||
|
|
f79538f6b2 | ||
|
|
a3a218fef1 | ||
|
|
0c6c3b8c43 | ||
|
|
a47cc0e711 | ||
|
|
ac3d6e1784 | ||
|
|
adacd8944b | ||
|
|
ea58dab71c | ||
|
|
cf3a3ad910 | ||
|
|
8660c675b6 | ||
|
|
1f4d67dc77 | ||
|
|
b0e23cb50a | ||
|
|
1e886a705d |
@@ -539,7 +539,21 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||
"/admin/tasks")
|
||||
}
|
||||
// Send push notifications to users who have this book in their library.
|
||||
// Fan-out in-app new-chapter notification to all users who have this book
|
||||
// in their library. Runs in background so it doesn't block the task loop.
|
||||
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
|
||||
result.Slug != "" && task.Kind != "catalogue" {
|
||||
go func() {
|
||||
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
title := result.Slug
|
||||
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
|
||||
"New chapters available",
|
||||
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
|
||||
"/books/"+result.Slug)
|
||||
}()
|
||||
}
|
||||
// Send Web Push notifications to subscribed browsers.
|
||||
if r.deps.WebPush != nil && r.deps.Store != nil &&
|
||||
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
||||
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
||||
|
||||
@@ -1639,3 +1639,56 @@ func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// NotifyUsersWithBook creates an in-app notification for every logged-in user
|
||||
// who has slug in their library. Errors for individual users are logged but
|
||||
// do not abort the loop. Returns the number of notifications created.
|
||||
func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
|
||||
userIDs, err := s.ListUserIDsWithBook(ctx, slug)
|
||||
if err != nil || len(userIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
for _, uid := range userIDs {
|
||||
if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
// who have slug in their user_library. Used to fan-out new-chapter notifications.
|
||||
// Admin users and users who have opted out of in-app new-chapter notifications
|
||||
// (notify_new_chapters=false on app_users) are excluded.
|
||||
func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
|
||||
// Collect user IDs to skip: admins + opted-out users.
|
||||
skipIDs := make(map[string]bool)
|
||||
excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
|
||||
if err == nil {
|
||||
for _, raw := range excludedItems {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
skipIDs[rec.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
|
||||
items, err := s.pb.listAll(ctx, "user_library", filter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var ids []string
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
|
||||
seen[rec.UserID] = true
|
||||
ids = append(ids, rec.UserID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -170,6 +170,21 @@ class AudioStore {
|
||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* When true the persistent mini-bar in +layout.svelte is hidden.
|
||||
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
|
||||
* so the in-page player is the sole control surface.
|
||||
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
|
||||
*/
|
||||
suppressMiniBar = $state(false);
|
||||
|
||||
/**
|
||||
* Position of the draggable float overlay (bottom-right anchor offsets).
|
||||
* Stored here (module singleton) so the position survives chapter navigation.
|
||||
* x > 0 = moved left; y > 0 = moved up.
|
||||
*/
|
||||
floatPos = $state({ x: 0, y: 0 });
|
||||
|
||||
/** True when the currently loaded track matches slug+chapter */
|
||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
@@ -71,8 +72,11 @@
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
|
||||
playerStyle?: 'standard' | 'float';
|
||||
/** Visual style of the player card.
|
||||
* 'standard' = full inline card with voice/chapter controls;
|
||||
* 'minimal' = compact single-row bar (play + seek + time only);
|
||||
* 'float' = draggable overlay anchored bottom-right. */
|
||||
playerStyle?: 'standard' | 'minimal' | 'float';
|
||||
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
|
||||
wordCount?: number;
|
||||
}
|
||||
@@ -942,24 +946,86 @@
|
||||
}
|
||||
|
||||
// ── Float player drag state ──────────────────────────────────────────────
|
||||
/** Position of the floating overlay (bottom-right anchor by default). */
|
||||
let floatPos = $state({ x: 0, y: 0 });
|
||||
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
|
||||
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
|
||||
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
|
||||
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
|
||||
//
|
||||
// To keep the circle in the viewport we clamp so that the element never goes
|
||||
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
|
||||
|
||||
const FLOAT_SIZE = 56; // px — must match w-14
|
||||
const FLOAT_MARGIN = 16; // px — 1rem
|
||||
|
||||
function clampFloatPos(x: number, y: number): { x: number; y: number } {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
|
||||
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
|
||||
const maxX = FLOAT_MARGIN;
|
||||
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
|
||||
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
|
||||
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
|
||||
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
|
||||
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
|
||||
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
|
||||
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
|
||||
const maxY = FLOAT_MARGIN;
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
};
|
||||
}
|
||||
|
||||
let floatDragging = $state(false);
|
||||
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
|
||||
// Track total pointer movement to distinguish tap vs drag
|
||||
let floatMoved = $state(false);
|
||||
|
||||
function onFloatPointerDown(e: PointerEvent) {
|
||||
e.stopPropagation();
|
||||
floatDragging = true;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
|
||||
floatMoved = false;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onFloatPointerMove(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
floatPos = {
|
||||
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
|
||||
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
|
||||
const dx = e.clientX - floatDragStart.mx;
|
||||
const dy = e.clientY - floatDragStart.my;
|
||||
// Only start moving if dragged > 6px to preserve tap detection
|
||||
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
|
||||
floatMoved = true;
|
||||
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
|
||||
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
|
||||
const raw = {
|
||||
x: floatDragStart.ox + dx,
|
||||
y: floatDragStart.oy + dy,
|
||||
};
|
||||
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
|
||||
}
|
||||
function onFloatPointerUp() { floatDragging = false; }
|
||||
function onFloatPointerUp(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
if (floatDragging && !floatMoved) {
|
||||
// Tap: toggle play/pause
|
||||
audioStore.toggleRequest++;
|
||||
}
|
||||
floatDragging = false;
|
||||
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Clamp saved position to viewport on mount and on resize.
|
||||
// Use untrack() when reading floatPos to avoid a reactive loop
|
||||
// (reading + writing the same state inside $effect would re-trigger forever).
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const clamp = () => {
|
||||
const { x, y } = untrack(() => audioStore.floatPos);
|
||||
audioStore.floatPos = clampFloatPos(x, y);
|
||||
};
|
||||
clamp();
|
||||
window.addEventListener('resize', clamp);
|
||||
return () => window.removeEventListener('resize', clamp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -1034,7 +1100,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Track info -->
|
||||
<!-- Track info (hidden in minimal style) -->
|
||||
{#if playerStyle !== 'minimal'}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
|
||||
{m.reader_play_narration()}
|
||||
@@ -1091,9 +1158,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapters button (right side) -->
|
||||
{#if chapters.length > 0}
|
||||
<!-- Chapters button (right side, hidden in minimal style) -->
|
||||
{#if chapters.length > 0 && playerStyle !== 'minimal'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
@@ -1167,6 +1235,67 @@
|
||||
{:else}
|
||||
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
|
||||
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
|
||||
|
||||
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
|
||||
<div class="px-3 py-2.5 flex items-center gap-2">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Seek bar — proper range input so drag works on iOS too -->
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
||||
onchange={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
||||
class="flex-1 h-1.5 cursor-pointer"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
@@ -1372,6 +1501,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
|
||||
Rendered as a top-level sibling (outside all player containers) so that
|
||||
@@ -1380,7 +1510,7 @@
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
@@ -1388,104 +1518,86 @@
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
Rendered outside all containers so fixed positioning is never clipped.
|
||||
A draggable circle anchored to the viewport.
|
||||
Tap = toggle play/pause.
|
||||
Drag = reposition (clamped to viewport).
|
||||
Visible when playerStyle='float' and audio is active for this chapter. -->
|
||||
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed z-[55] select-none"
|
||||
style="
|
||||
bottom: calc(4.5rem + {-floatPos.y}px);
|
||||
right: calc(1rem + {-floatPos.x}px);
|
||||
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
|
||||
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
|
||||
touch-action: none;
|
||||
width: {FLOAT_SIZE}px;
|
||||
height: {FLOAT_SIZE}px;
|
||||
"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
|
||||
>
|
||||
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
|
||||
<!-- Drag handle + title row -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={onFloatPointerUp}
|
||||
>
|
||||
<!-- Drag grip dots -->
|
||||
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||||
<!-- Pulsing ring when playing -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Circle button -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
|
||||
>
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-6 h-6 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
|
||||
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
|
||||
</span>
|
||||
<!-- Status dot -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
|
||||
onclick={seekFromBar}
|
||||
>
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center gap-1 px-3 pb-2.5">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}
|
||||
<span class="opacity-50">/</span>
|
||||
{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
|
||||
<!-- Speed -->
|
||||
<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
|
||||
{audioStore.speed}×
|
||||
</span>
|
||||
</div>
|
||||
{:else if audioStore.isPlaying}
|
||||
<!-- Pause icon -->
|
||||
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Play icon -->
|
||||
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress arc ring (thin, overlaid on circle edge) -->
|
||||
{#if audioStore.duration > 0}
|
||||
{@const r = 26}
|
||||
{@const circ = 2 * Math.PI * r}
|
||||
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
|
||||
<svg
|
||||
class="absolute inset-0 pointer-events-none -rotate-90"
|
||||
width={FLOAT_SIZE}
|
||||
height={FLOAT_SIZE}
|
||||
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
|
||||
>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{circ}"
|
||||
stroke-dashoffset="{circ - dash}"
|
||||
style="transition: stroke-dashoffset 0.5s linear;"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
@@ -98,7 +98,6 @@
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -434,17 +433,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 +658,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}
|
||||
|
||||
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
notifications: Notification[];
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
onclose: () => void;
|
||||
onMarkRead: (id: string) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onDismiss: (id: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
notifications,
|
||||
userId,
|
||||
isAdmin,
|
||||
onclose,
|
||||
onMarkRead,
|
||||
onMarkAllRead,
|
||||
onDismiss,
|
||||
onClearAll,
|
||||
}: Props = $props();
|
||||
|
||||
let filter = $state<'all' | 'unread'>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
// Body scroll lock + Escape to close
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
const viewAllHref = $derived(isAdmin ? '/admin/notifications' : '/notifications');
|
||||
</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(); }}
|
||||
>
|
||||
<!-- Modal panel — slides down from top -->
|
||||
<!-- 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 justify-between px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base font-semibold text-(--color-text)">Notifications</span>
|
||||
{#if unreadCount > 0}
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||
{unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClearAll}
|
||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
<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 notifications"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 px-4 py-2 border-b border-(--color-border)/60 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'all'}
|
||||
class={cn(
|
||||
'text-xs px-3 py-1.5 rounded-l border border-(--color-border) transition-colors',
|
||||
filter === 'all'
|
||||
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'unread'}
|
||||
class={cn(
|
||||
'text-xs px-3 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors',
|
||||
filter === 'unread'
|
||||
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable list -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain min-h-0">
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted) text-sm">
|
||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filtered as n (n.id)}
|
||||
<div class={cn(
|
||||
'flex items-start gap-1 border-b border-(--color-border)/40 last:border-0 hover:bg-(--color-surface-2) group transition-colors',
|
||||
n.read && 'opacity-60'
|
||||
)}>
|
||||
<a
|
||||
href={n.link || (isAdmin ? '/admin' : '/')}
|
||||
onclick={() => { onMarkRead(n.id); onclose(); }}
|
||||
class="flex-1 px-4 py-3.5 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !n.read}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-(--color-text) truncate">{n.title}</span>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDismiss(n.id)}
|
||||
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-(--color-border)/40 shrink-0">
|
||||
<a
|
||||
href={viewAllHref}
|
||||
onclick={onclose}
|
||||
class="block text-center text-sm text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||
>View all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +95,8 @@ export interface User {
|
||||
oauth_id?: string;
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -1164,6 +1166,101 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
|
||||
return new Set(jobs.map((j) => j.slug));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns books that have at least one completed audio chapter, sorted by
|
||||
* number of narrated chapters descending.
|
||||
* Cached for 5 minutes (same TTL as the catalogue audio badge).
|
||||
*/
|
||||
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
|
||||
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
|
||||
|
||||
export interface AudioBookEntry {
|
||||
book: Book;
|
||||
audioChapters: number;
|
||||
}
|
||||
|
||||
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
|
||||
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
|
||||
if (cached) return cached.slice(0, limit);
|
||||
|
||||
// Count done jobs per slug
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||||
const countBySlug = new Map<string, number>();
|
||||
for (const j of jobs) {
|
||||
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
|
||||
// by chapter number so we count chapters, not voice variants.
|
||||
// cache_key format: "slug/chapter/voice"
|
||||
const slug = j.slug;
|
||||
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
|
||||
// We'll use a Set per slug after this loop instead
|
||||
}
|
||||
// Build slug → Set<chapter> to deduplicate voice variants
|
||||
const chapsBySlug = new Map<string, Set<number>>();
|
||||
for (const j of jobs) {
|
||||
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
|
||||
chapsBySlug.get(j.slug)!.add(j.chapter);
|
||||
}
|
||||
|
||||
const slugs = [...chapsBySlug.keys()];
|
||||
if (slugs.length === 0) return [];
|
||||
|
||||
const books = await getBooksBySlugs(slugs);
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
|
||||
const entries: AudioBookEntry[] = [];
|
||||
for (const [slug, chapters] of chapsBySlug) {
|
||||
const book = bookMap.get(slug);
|
||||
if (!book) continue;
|
||||
entries.push({ book, audioChapters: chapters.size });
|
||||
}
|
||||
// Sort by most chapters narrated first
|
||||
entries.sort((a, b) => b.audioChapters - a.audioChapters);
|
||||
|
||||
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
|
||||
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 {
|
||||
@@ -1481,6 +1578,28 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's notification preferences (stored on app_users record).
|
||||
*/
|
||||
export async function updateUserNotificationPrefs(
|
||||
userId: string,
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PBBookComment {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
@@ -26,7 +27,6 @@
|
||||
// Notifications
|
||||
let notificationsOpen = $state(false);
|
||||
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
||||
let notifFilter = $state<'all' | 'unread'>('all');
|
||||
async function loadNotifications() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
@@ -64,10 +64,8 @@
|
||||
} 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);
|
||||
const filteredNotifications = $derived(
|
||||
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
|
||||
// Close search on navigation
|
||||
$effect(() => {
|
||||
@@ -515,7 +513,7 @@
|
||||
style="display:none"
|
||||
></audio>
|
||||
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
|
||||
@@ -573,27 +571,25 @@
|
||||
</a>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Universal search button (hidden on chapter/reader pages) -->
|
||||
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Universal search button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="relative">
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||
title="Notifications"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
|
||||
>
|
||||
@@ -604,87 +600,6 @@
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if notificationsOpen}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 flex flex-col max-h-[28rem]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
|
||||
<span class="text-sm font-semibold">Notifications</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAllNotifications}
|
||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 px-3 pb-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'all'}
|
||||
class="text-xs px-2.5 py-1 rounded-l border border-(--color-border) transition-colors {notifFilter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'unread'}
|
||||
class="text-xs px-2.5 py-1 rounded-r border border-l-0 border-(--color-border) transition-colors {notifFilter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="overflow-y-auto flex-1 min-h-0">
|
||||
{#if filteredNotifications.length === 0}
|
||||
<div class="p-4 text-center text-(--color-muted) text-sm">
|
||||
{notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredNotifications as n (n.id)}
|
||||
<div class="flex items-start gap-1 border-b border-(--color-border)/40 hover:bg-(--color-surface-3) group {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/admin'}
|
||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
||||
class="flex-1 p-3 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !n.read}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-medium truncate">{n.title}</span>
|
||||
</div>
|
||||
<div class="text-xs text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismissNotification(n.id)}
|
||||
class="shrink-0 p-2.5 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-3 h-3" 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>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="px-3 py-2 border-t border-(--color-border)/40 shrink-0">
|
||||
<a
|
||||
href="/admin/notifications"
|
||||
onclick={() => notificationsOpen = false}
|
||||
class="block text-center text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||
>View all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
@@ -829,14 +744,15 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -969,6 +885,17 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
|
||||
only affects page content below, not the drawer items themselves -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={() => { menuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
||||
{#key page.url.pathname + page.url.search}
|
||||
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
|
||||
@@ -1043,7 +970,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
{#if audioStore.active && !audioStore.suppressMiniBar}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
@@ -1064,6 +991,7 @@
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
onchange={seek}
|
||||
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
|
||||
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
|
||||
/>
|
||||
@@ -1222,12 +1150,24 @@
|
||||
<SearchModal onclose={() => { searchOpen = false; }} />
|
||||
{/if}
|
||||
|
||||
<!-- Notifications modal — full-screen, shown for all logged-in users -->
|
||||
{#if notificationsOpen && data.user}
|
||||
<NotificationsModal
|
||||
notifications={notifications}
|
||||
userId={data.user.id}
|
||||
isAdmin={data.user.role === 'admin'}
|
||||
onclose={() => { notificationsOpen = false; }}
|
||||
onMarkRead={markRead}
|
||||
onMarkAllRead={markAllRead}
|
||||
onDismiss={dismissNotification}
|
||||
onClearAll={clearAllNotifications}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={(e) => {
|
||||
// Don't intercept when typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
|
||||
// Don't open on chapter reader pages
|
||||
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
|
||||
if (searchOpen) return;
|
||||
// `/` key or Cmd/Ctrl+K
|
||||
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
getHomeStats,
|
||||
getSubscriptionFeed,
|
||||
getTrendingBooks,
|
||||
getRecommendedBooks
|
||||
getRecommendedBooks,
|
||||
getBooksWithAudioCount
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
@@ -87,8 +88,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Fetch trending, recommendations, and subscription feed in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
|
||||
// Fetch trending, recommendations, subscription feed, and audio books in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
|
||||
getTrendingBooks(8).catch(() => [] as Book[]),
|
||||
topGenres.length > 0
|
||||
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
||||
@@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: Promise.resolve([])
|
||||
: Promise.resolve([]),
|
||||
getBooksWithAudioCount(20).catch(() => [])
|
||||
]);
|
||||
|
||||
// Strip books the user is already reading from trending (redundant)
|
||||
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
||||
|
||||
// Strip already-reading books from audio shelf; cap at 8
|
||||
const readyToListen = audioBooks
|
||||
.filter((e) => !inProgressSlugs.has(e.book.slug))
|
||||
.slice(0, 8);
|
||||
|
||||
return {
|
||||
continueInProgress,
|
||||
continueCompleted,
|
||||
@@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
subscriptionFeed,
|
||||
trendingBooks: trendingFiltered,
|
||||
recommendedBooks,
|
||||
readyToListen,
|
||||
topGenre: topGenres[0] ?? null,
|
||||
stats: {
|
||||
...stats,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Section visibility ────────────────────────────────────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
|
||||
const SECTIONS_KEY = 'home_sections_v1';
|
||||
|
||||
function loadHidden(): Set<SectionId> {
|
||||
@@ -40,6 +40,7 @@
|
||||
'from-following': 'From Following',
|
||||
'trending': 'Trending Now',
|
||||
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
||||
'ready-to-listen': 'Ready to Listen',
|
||||
});
|
||||
|
||||
const hiddenList = $derived(
|
||||
@@ -307,6 +308,69 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Ready to Listen shelf ──────────────────────────────────────────────────── -->
|
||||
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Ready to Listen</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.readyToListen as { book, audioChapters }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Headphones badge -->
|
||||
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
<svg class="w-3 h-3" 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>
|
||||
{audioChapters} ch
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
</a>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Listen Ch.1 button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, 1)}
|
||||
class="mx-2 mb-2 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
|
||||
aria-label="Listen from chapter 1"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Listen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
||||
{#if !hidden.has('browse-genre')}
|
||||
<section class="mb-10">
|
||||
|
||||
17
ui/src/routes/api/audio/books/+server.ts
Normal file
17
ui/src/routes/api/audio/books/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/audio/books
|
||||
* Returns books that have at least one completed narrated chapter,
|
||||
* sorted by number of narrated chapters descending.
|
||||
* Cached 5 minutes at the CDN/proxy level.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const entries = await getBooksWithAudioCount(100).catch(() => []);
|
||||
return json(
|
||||
{ books: entries },
|
||||
{ headers: { 'Cache-Control': 'public, max-age=300' } }
|
||||
);
|
||||
};
|
||||
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' }
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,46 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteUserAccount } from '$lib/server/pocketbase';
|
||||
import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* PATCH /api/profile
|
||||
*
|
||||
* Update mutable profile preferences (currently: notification preferences).
|
||||
* Body: { notify_new_chapters?: boolean, notify_new_chapters_push?: boolean }
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUserNotificationPrefs(locals.user.id, prefs);
|
||||
} catch (e) {
|
||||
log.error('profile', 'PATCH /api/profile failed', { userId: locals.user.id, err: String(e) });
|
||||
error(500, { message: 'Failed to update preferences. Please try again.' });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/profile
|
||||
*
|
||||
|
||||
@@ -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' },
|
||||
@@ -52,7 +56,7 @@
|
||||
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'float';
|
||||
type PlayerStyle = 'standard' | 'minimal' | 'float';
|
||||
/** Controls how many lines fit on a page by adjusting the container height offset. */
|
||||
type PageLines = 'less' | 'normal' | 'more';
|
||||
|
||||
@@ -100,6 +104,33 @@
|
||||
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
|
||||
});
|
||||
|
||||
// ── Suppress mini-bar for float / minimal player styles ──────────────────────
|
||||
// The in-page player is the sole control surface for these styles; the layout
|
||||
// mini-bar would be a duplicate. Clear on page destroy so the mini-bar returns
|
||||
// on other pages (library, catalogue, etc.) where audio may still be playing.
|
||||
$effect(() => {
|
||||
audioStore.suppressMiniBar = layout.playerStyle === 'float' || layout.playerStyle === 'minimal';
|
||||
return () => { audioStore.suppressMiniBar = false; };
|
||||
});
|
||||
|
||||
// ── Persist float overlay position across reloads ─────────────────────────────
|
||||
const FLOAT_POS_KEY = 'reader_float_pos_v1';
|
||||
if (browser) {
|
||||
try {
|
||||
const saved = localStorage.getItem(FLOAT_POS_KEY);
|
||||
if (saved) {
|
||||
const p = JSON.parse(saved) as { x: number; y: number };
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') audioStore.floatPos = p;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
$effect(() => {
|
||||
const pos = audioStore.floatPos;
|
||||
if (browser && (pos.x !== 0 || pos.y !== 0)) {
|
||||
try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scroll progress bar ──────────────────────────────────────────────────────
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
@@ -400,6 +431,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>
|
||||
@@ -483,6 +521,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>
|
||||
@@ -573,6 +623,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
|
||||
@@ -596,27 +667,27 @@
|
||||
</button>
|
||||
{#if audioExpanded}
|
||||
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
|
||||
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
|
||||
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
|
||||
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
|
||||
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span>Controls are in the player bar below.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
wordCount={wordCount}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active && layout.playerStyle === 'standard'}
|
||||
<!-- Mini-player is already playing this chapter — don't duplicate controls (standard/minimal mode) -->
|
||||
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
|
||||
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span>Controls are in the player bar below.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
wordCount={wordCount}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -793,9 +864,70 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||
{@const atTop = scrollProgress <= 0.01}
|
||||
{@const atBottom = scrollProgress >= 0.99}
|
||||
<div class="fixed right-4 {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
|
||||
<!-- Up button / Prev chapter -->
|
||||
{#if atTop && data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<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="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => window.scrollBy({ top: -Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
|
||||
disabled={atTop}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
|
||||
title="Scroll up"
|
||||
aria-label="Scroll up"
|
||||
>
|
||||
<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="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Down button / Next chapter -->
|
||||
{#if atBottom && data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-brand) shadow-lg text-black hover:bg-(--color-brand-dim) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => window.scrollBy({ top: Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
|
||||
disabled={atBottom && !data.next}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
|
||||
title="Scroll down"
|
||||
aria-label="Scroll down"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
|
||||
{#if layout.focusMode}
|
||||
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="fixed {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
|
||||
<!-- Prev chapter -->
|
||||
{#if data.prev}
|
||||
@@ -1108,7 +1240,7 @@
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['standard', 'Standard'], ['float', 'Float']] as const) as [s, lbl]}
|
||||
{#each ([['standard', 'Standard'], ['minimal', 'Minimal'], ['float', 'Float']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
@@ -1121,6 +1253,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-2.5">
|
||||
<p class="text-[11px] text-(--color-muted)/70 leading-snug">
|
||||
{#if layout.playerStyle === 'standard'}
|
||||
Full panel with voice picker and chapter browser.
|
||||
{:else if layout.playerStyle === 'minimal'}
|
||||
Compact bar: play/pause, seek, and time only.
|
||||
{:else}
|
||||
Draggable overlay — stays visible while you read.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
@@ -1188,3 +1331,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Currently Reading modal -->
|
||||
{#if readingModalOpen}
|
||||
<CurrentlyReadingModal
|
||||
currentSlug={data.book.slug}
|
||||
onclose={() => (readingModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
11
ui/src/routes/listen/+page.server.ts
Normal file
11
ui/src/routes/listen/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const sort = url.searchParams.get('sort') ?? 'chapters';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
|
||||
const audioBooks = await getBooksWithAudioCount(200).catch(() => []);
|
||||
|
||||
return { audioBooks, sort, q };
|
||||
};
|
||||
203
ui/src/routes/listen/+page.svelte
Normal file
203
ui/src/routes/listen/+page.svelte
Normal file
@@ -0,0 +1,203 @@
|
||||
<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(untrack(() => data.q));
|
||||
let sort = $state(untrack(() => data.sort));
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let list = data.audioBooks;
|
||||
|
||||
// text filter
|
||||
if (q.trim()) {
|
||||
const needle = q.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
({ book }) =>
|
||||
book.title?.toLowerCase().includes(needle) ||
|
||||
book.author?.toLowerCase().includes(needle)
|
||||
);
|
||||
}
|
||||
|
||||
// sort
|
||||
if (sort === 'title') {
|
||||
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.meta_updated ?? '';
|
||||
const db = b.book.meta_updated ?? '';
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
}
|
||||
// default: 'chapters' — already sorted by getBooksWithAudioCount
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
goto(`/books/${slug}/chapters/${chapter}`);
|
||||
}
|
||||
|
||||
function onSortChange(value: string) {
|
||||
sort = value;
|
||||
const params = new URLSearchParams();
|
||||
if (value !== 'chapters') params.set('sort', value);
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
const qs = params.toString();
|
||||
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
function onSearch(e: Event) {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (sort !== 'chapters') params.set('sort', sort);
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
const qs = params.toString();
|
||||
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Narrated Books — LibNovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<svg class="w-5 h-5 text-(--color-brand)" 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>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Narrated Books</h1>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted)">Books with generated TTS audio ready to listen</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<!-- Search -->
|
||||
<form onsubmit={onSearch} class="flex-1 flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Search by title or author…"
|
||||
class="flex-1 min-w-0 px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder:text-(--color-muted) text-sm focus:outline-none focus:border-(--color-brand)/60 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 text-sm transition-colors shrink-0"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#each [['chapters', 'Most narrated'], ['title', 'A–Z'], ['recent', 'Recent']] as [val, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSortChange(val)}
|
||||
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors {sort === val
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results count -->
|
||||
{#if filtered.length > 0}
|
||||
<p class="text-xs text-(--color-muted) mb-4">{filtered.length} book{filtered.length !== 1 ? 's' : ''}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Grid -->
|
||||
{#if filtered.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
{#if q.trim()}
|
||||
<p class="text-base font-semibold text-(--color-text) mb-2">No results for "{q}"</p>
|
||||
<p class="text-sm">Try a different search term.</p>
|
||||
{:else}
|
||||
<p class="text-base font-semibold text-(--color-text) mb-2">No narrated books yet</p>
|
||||
<p class="text-sm">Audio is generated as books are read. Check back soon.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{#each filtered as { book, audioChapters }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<div class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Headphones badge -->
|
||||
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
<svg class="w-3 h-3" 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>
|
||||
{audioChapters} ch
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
</a>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-2 pb-2 flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, 1)}
|
||||
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
|
||||
aria-label="Listen from chapter 1"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Listen
|
||||
</button>
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="flex items-center justify-center px-2 py-1.5 rounded-md bg-(--color-surface-3) hover:bg-(--color-surface) border border-(--color-border) hover:border-(--color-brand)/40 text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
title="Book info"
|
||||
aria-label="Book info"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
28
ui/src/routes/notifications/+page.server.ts
Normal file
28
ui/src/routes/notifications/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Admins have their own full notifications page
|
||||
if (locals.user?.role === 'admin') {
|
||||
redirect(302, '/admin/notifications');
|
||||
}
|
||||
|
||||
const userId = locals.user!.id;
|
||||
try {
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return {
|
||||
userId,
|
||||
notifications: (data.notifications ?? []) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link: string;
|
||||
read: boolean;
|
||||
}>
|
||||
};
|
||||
} catch {
|
||||
return { userId, notifications: [] };
|
||||
}
|
||||
};
|
||||
127
ui/src/routes/notifications/+page.svelte
Normal file
127
ui/src/routes/notifications/+page.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
type Notification = { id: string; title: string; message: string; link: string; read: boolean };
|
||||
|
||||
let notifications = $state<Notification[]>(data.notifications);
|
||||
let filter = $state<'all' | 'unread'>('all');
|
||||
let busy = $state(false);
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
async function markRead(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'PATCH' }).catch(() => {});
|
||||
notifications = notifications.map(n => n.id === id ? { ...n, read: true } : n);
|
||||
}
|
||||
|
||||
async function dismiss(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'DELETE' }).catch(() => {});
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm('Clear all notifications?')) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'DELETE' });
|
||||
notifications = [];
|
||||
} finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Notifications</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Notifications</h1>
|
||||
{#if unreadCount > 0}
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-red-400 hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'all'}
|
||||
class="text-sm px-4 py-1.5 rounded-l border border-(--color-border) transition-colors {filter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'unread'}
|
||||
class="text-sm px-4 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors {filter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted)">
|
||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
{#each filtered as n (n.id)}
|
||||
<div class="flex items-start gap-2 border-b border-(--color-border)/40 last:border-b-0 hover:bg-(--color-surface-2) group transition-colors {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/'}
|
||||
onclick={() => markRead(n.id)}
|
||||
class="flex-1 p-4 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !n.read}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="font-medium text-sm">{n.title}</span>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted) mt-1">{n.message}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismiss(n.id)}
|
||||
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getUserByUsername,
|
||||
getUserStats,
|
||||
allProgress,
|
||||
getBooksBySlugs
|
||||
getBooksBySlugs,
|
||||
getUserById
|
||||
} from '$lib/server/pocketbase';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
@@ -41,12 +42,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: fetch fresh user record (for notification prefs not in auth token)
|
||||
async function fetchFreshUser() {
|
||||
return getUserById(locals.user!.id);
|
||||
}
|
||||
|
||||
// Run all three independent groups concurrently
|
||||
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
|
||||
const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([
|
||||
fetchUserRecord(),
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id),
|
||||
fetchHistory()
|
||||
fetchHistory(),
|
||||
fetchFreshUser()
|
||||
]);
|
||||
|
||||
if (userRecord.status === 'rejected')
|
||||
@@ -57,7 +64,6 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
|
||||
if (historyResult.status === 'rejected')
|
||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
||||
|
||||
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
||||
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
||||
const sessions =
|
||||
@@ -66,12 +72,16 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
||||
const history =
|
||||
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
||||
const freshUser =
|
||||
freshUserResult.status === 'fulfilled' ? freshUserResult.value : null;
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
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