Compare commits
10 Commits
v2.6.75
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4631e7486 | ||
|
|
015cb8a0cd | ||
|
|
53edb6fdef | ||
|
|
f79538f6b2 | ||
|
|
a3a218fef1 | ||
|
|
0c6c3b8c43 | ||
|
|
a47cc0e711 | ||
|
|
ac3d6e1784 | ||
|
|
adacd8944b | ||
|
|
ea58dab71c |
@@ -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';
|
||||
@@ -946,22 +947,85 @@
|
||||
|
||||
// ── Float player drag state ──────────────────────────────────────────────
|
||||
// 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;
|
||||
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;
|
||||
audioStore.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} />
|
||||
@@ -1212,15 +1276,18 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="flex-1 h-1.5 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>
|
||||
<!-- 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)">
|
||||
@@ -1443,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; }}
|
||||
@@ -1451,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(1rem + {-audioStore.floatPos.y}px);
|
||||
right: calc(1rem + {-audioStore.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;
|
||||
"
|
||||
>
|
||||
<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}
|
||||
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
|
||||
>
|
||||
<!-- 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"/>
|
||||
</svg>
|
||||
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
|
||||
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
|
||||
</span>
|
||||
<!-- Status dot -->
|
||||
<!-- Pulsing ring when playing -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
|
||||
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<!-- Circle button -->
|
||||
<div
|
||||
role="none"
|
||||
class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
|
||||
onclick={seekFromBar}
|
||||
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"
|
||||
>
|
||||
<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"/>
|
||||
{#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>
|
||||
{: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>
|
||||
</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"/>
|
||||
<!-- 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>
|
||||
</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>
|
||||
{/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}
|
||||
|
||||
@@ -1165,6 +1165,60 @@ 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);
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
|
||||
@@ -570,8 +570,7 @@
|
||||
</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)}
|
||||
<!-- Universal search button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
@@ -583,7 +582,6 @@
|
||||
<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}
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user}
|
||||
@@ -754,15 +752,6 @@
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Backdrop for mobile hamburger menu -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed inset-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}
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -895,6 +884,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 }}>
|
||||
@@ -990,6 +990,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);"
|
||||
/>
|
||||
@@ -1166,8 +1167,6 @@
|
||||
// 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' } }
|
||||
);
|
||||
};
|
||||
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 };
|
||||
};
|
||||
202
ui/src/routes/listen/+page.svelte
Normal file
202
ui/src/routes/listen/+page.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let q = $state(data.q);
|
||||
let sort = $state(data.sort);
|
||||
|
||||
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.updated ?? a.book.created ?? '';
|
||||
const db = b.book.updated ?? b.book.created ?? '';
|
||||
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}
|
||||
@@ -84,10 +84,6 @@
|
||||
function handleCropCancel() { cropFile = null; }
|
||||
|
||||
// ── Settings state ────────────────────────────────────────────────────────────
|
||||
// All changes are written directly into audioStore / theme context.
|
||||
// The layout's debounced $effect owns the single PUT /api/settings call.
|
||||
// We only maintain a local saveStatus indicator here.
|
||||
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||||
@@ -118,7 +114,6 @@
|
||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||
];
|
||||
|
||||
// Local save-status indicator — layout's effect does the actual debounced save.
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||
let saveStatus = $state<SaveStatus>('idle');
|
||||
let savedTimer = 0;
|
||||
@@ -133,8 +128,6 @@
|
||||
}, 900) as unknown as number;
|
||||
}
|
||||
|
||||
// Propagate all settings changes into audioStore / context immediately.
|
||||
// Layout effect watches these and persists to the server (debounced 800ms).
|
||||
$effect(() => {
|
||||
const t = selectedTheme;
|
||||
const ff = selectedFontFamily;
|
||||
@@ -160,8 +153,13 @@
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
||||
|
||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
|
||||
// ── Expanded section state ────────────────────────────────────────────────────
|
||||
type Section = 'history' | 'stats' | 'appearance' | 'playback' | 'notifications' | 'subscription' | 'sessions' | 'danger' | null;
|
||||
let expanded = $state<Section>(null);
|
||||
|
||||
function toggle(section: Section) {
|
||||
expanded = expanded === section ? null : section;
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
type Session = {
|
||||
@@ -197,7 +195,6 @@
|
||||
}
|
||||
|
||||
// ── Danger zone ──────────────────────────────────────────────────────────────
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteConfirmText = $state('');
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state('');
|
||||
@@ -265,7 +262,6 @@
|
||||
pushState = 'unsupported';
|
||||
return;
|
||||
}
|
||||
// Check current permission / subscription state
|
||||
(async () => {
|
||||
const perm = Notification.permission;
|
||||
if (perm === 'denied') { pushState = 'denied'; return; }
|
||||
@@ -280,15 +276,12 @@
|
||||
pushError = '';
|
||||
pushState = 'loading';
|
||||
try {
|
||||
// Fetch VAPID public key from backend
|
||||
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
|
||||
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
|
||||
const { public_key } = await keyRes.json() as { public_key: string };
|
||||
|
||||
// Register / get existing service worker
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
// Subscribe with VAPID
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
@@ -334,7 +327,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -356,6 +348,9 @@
|
||||
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
|
||||
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
|
||||
}
|
||||
|
||||
// ── Chevron helper ────────────────────────────────────────────────────────────
|
||||
const chevronClass = 'w-4 h-4 text-(--color-muted) shrink-0 transition-transform duration-200';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -370,9 +365,9 @@
|
||||
|
||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||
<div class="max-w-lg mx-auto space-y-5 pb-16">
|
||||
|
||||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||||
<!-- ── Post-checkout success banner ─────────────────────────────────────── -->
|
||||
{#if justSubscribed}
|
||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
|
||||
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||
@@ -381,11 +376,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="flex items-center gap-4 pt-2 px-1">
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||||
class="group relative w-18 h-18 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||||
title={m.profile_change_avatar()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
@@ -405,16 +400,19 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
||||
{#if data.isPro}
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
||||
@@ -423,128 +421,212 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
|
||||
{#each (['profile', 'stats', 'history'] as const) as tab}
|
||||
<!-- ── Library group ─────────────────────────────────────────────────────── -->
|
||||
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||
|
||||
<!-- Favourites -->
|
||||
<a
|
||||
href="/books?status=reading"
|
||||
class="flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Library</span>
|
||||
<svg class={chevronClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Stats -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab)}
|
||||
class={cn(
|
||||
'flex-1 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
activeTab === tab
|
||||
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
onclick={() => toggle('stats')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Stats</span>
|
||||
<svg class={cn(chevronClass, expanded === 'stats' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded === 'stats'}
|
||||
<div class="px-5 pb-5 pt-3 space-y-4 bg-(--color-surface-3)/30">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted },
|
||||
{ label: 'Reading', value: data.stats.booksReading },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-2) rounded-lg p-3 text-center border border-(--color-border)">
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'profile'}
|
||||
|
||||
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
|
||||
{#if !data.isPro}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="bg-(--color-surface-2) rounded-lg p-3 border border-(--color-border)">
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">day streak</p>
|
||||
</div>
|
||||
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
||||
{m.profile_plan_free()}
|
||||
<div class="bg-(--color-surface-2) rounded-lg p-3 border border-(--color-border)">
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">avg rating</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.stats.topGenres.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class={cn(
|
||||
'px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
i === 0
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-2) text-(--color-text) border border-(--color-border)'
|
||||
)}>
|
||||
{genre}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
|
||||
{#if checkoutError}
|
||||
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('monthly')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'monthly'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{/if}
|
||||
{m.profile_upgrade_monthly()}
|
||||
onclick={() => toggle('history')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Reading History</span>
|
||||
<svg class={cn(chevronClass, expanded === 'history' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('annual')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'annual'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
|
||||
{#if expanded === 'history'}
|
||||
<div class="bg-(--color-surface-3)/30">
|
||||
{#if data.history.length === 0}
|
||||
<p class="px-5 py-6 text-sm text-(--color-muted) text-center">No reading history yet.</p>
|
||||
{:else}
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
<ul class="divide-y divide-(--color-border)">
|
||||
{#each data.history as item}
|
||||
<li>
|
||||
<a
|
||||
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||
class="flex items-center gap-3 px-5 py-3 hover:bg-(--color-surface-3)/60 transition-colors group"
|
||||
>
|
||||
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Ch. {item.chapter}</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
|
||||
{m.profile_manage_subscription()} →
|
||||
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||||
{#if item.updated}
|
||||
{(() => {
|
||||
const ms = Date.now() - new Date(item.updated).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
})()}
|
||||
{/if}
|
||||
</p>
|
||||
</a>
|
||||
</section>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
|
||||
<!-- ── Settings group ────────────────────────────────────────────────────── -->
|
||||
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||
|
||||
<!-- Appearance -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle('appearance')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_appearance_heading()}</span>
|
||||
<span class="text-xs text-(--color-muted) mr-2 capitalize hidden sm:inline">{selectedTheme.replace('-', ' ')}</span>
|
||||
<svg class={cn(chevronClass, expanded === 'appearance' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded === 'appearance'}
|
||||
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Appearance</span>
|
||||
<span class={cn(
|
||||
'text-xs transition-all duration-300',
|
||||
saveStatus === 'saving' ? 'text-(--color-muted)' :
|
||||
saveStatus === 'saved' ? 'text-green-400' :
|
||||
'opacity-0 pointer-events-none'
|
||||
)}>
|
||||
{#if saveStatus === 'saving'}{m.profile_saving()}…
|
||||
{#if saveStatus === 'saving'}{m.profile_saving()}
|
||||
{:else if saveStatus === 'saved'}✓ {m.profile_saved()}
|
||||
{:else}{m.profile_saved()}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<div class="space-y-2.5">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 6}
|
||||
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
|
||||
<span class="w-px h-5 bg-(--color-border) mx-0.5 self-center"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTheme = t.id)}
|
||||
class={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors',
|
||||
selectedTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label()}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -552,7 +634,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Font family -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<div class="space-y-2.5">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each FONTS as f}
|
||||
@@ -560,10 +642,10 @@
|
||||
type="button"
|
||||
onclick={() => (selectedFontFamily = f.id)}
|
||||
class={cn(
|
||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
'px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedFontFamily === f.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedFontFamily === f.id}
|
||||
>
|
||||
@@ -574,7 +656,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Text size -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<div class="space-y-2.5">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each FONT_SIZES as s}
|
||||
@@ -582,10 +664,10 @@
|
||||
type="button"
|
||||
onclick={() => (selectedFontSize = s.value)}
|
||||
class={cn(
|
||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
'px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedFontSize === s.value
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedFontSize === s.value}
|
||||
>
|
||||
@@ -594,9 +676,34 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Playback speed -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<!-- Playback -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle('playback')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a9 9 0 000-12.728M9 10a3 3 0 000 4"/>
|
||||
<circle cx="7" cy="12" r="1.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Playback</span>
|
||||
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{audioStore.speed.toFixed(1)}x</span>
|
||||
<svg class={cn(chevronClass, expanded === 'playback' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded === 'playback'}
|
||||
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Playback</span>
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
|
||||
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
|
||||
@@ -609,10 +716,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playback toggles -->
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<p class="text-sm font-medium text-(--color-text)">Playback</p>
|
||||
|
||||
<!-- Auto-advance -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -685,31 +788,231 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
<!-- Notifications -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle('notifications')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Notifications</span>
|
||||
<span class="text-xs mr-2 hidden sm:inline {notifyNewChapters ? 'text-(--color-brand)' : 'text-(--color-muted)'}">
|
||||
{notifyNewChapters ? 'On' : 'Off'}
|
||||
</span>
|
||||
<svg class={cn(chevronClass, expanded === 'notifications' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
|
||||
{#if expanded === 'notifications'}
|
||||
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Notifications</span>
|
||||
|
||||
<!-- In-app -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">In-app notifications</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if notifyNewChapters}
|
||||
Notified when new chapters arrive in your library.
|
||||
{:else}
|
||||
In-app new-chapter notifications are disabled.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleNotifyNewChapters}
|
||||
disabled={notifyNewChaptersSaving}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={notifyNewChapters}
|
||||
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Push -->
|
||||
{#if pushState !== 'unsupported'}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">Push notifications</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if pushState === 'subscribed'}
|
||||
Push enabled for new chapters in your library.
|
||||
{:else if pushState === 'denied'}
|
||||
Blocked by your browser. Change in browser settings.
|
||||
{:else}
|
||||
Get notified when new chapters arrive.
|
||||
{/if}
|
||||
</p>
|
||||
{#if pushError}
|
||||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
{#if pushState === 'subscribed'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={unsubscribePush}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
|
||||
>
|
||||
Turn off
|
||||
</button>
|
||||
{:else if pushState === 'denied'}
|
||||
<span class="text-xs text-(--color-muted) italic">Blocked</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={subscribePush}
|
||||
disabled={pushState === 'loading'}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{#if pushState === 'loading'}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
Turn on
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Account group ─────────────────────────────────────────────────────── -->
|
||||
<div class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) overflow-hidden">
|
||||
|
||||
<!-- Subscription -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle('subscription')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_subscription_heading()}</span>
|
||||
<span class={cn(
|
||||
'text-xs font-semibold mr-2 px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center',
|
||||
data.isPro
|
||||
? 'bg-(--color-brand)/15 text-(--color-brand) border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border)'
|
||||
)}>
|
||||
{data.isPro ? m.profile_plan_pro() : m.profile_plan_free()}
|
||||
</span>
|
||||
<svg class={cn(chevronClass, expanded === 'subscription' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded === 'subscription'}
|
||||
<div class="px-5 py-5 bg-(--color-surface-3)/30">
|
||||
{#if data.isPro}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline whitespace-nowrap">
|
||||
{m.profile_manage_subscription()} →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1 border-t border-(--color-border)">
|
||||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
|
||||
{#if checkoutError}
|
||||
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('monthly')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'monthly'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{/if}
|
||||
{m.profile_upgrade_monthly()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('annual')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'annual'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{:else}
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active sessions -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle('sessions')}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-brand) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v10a2 2 0 01-2 2h-2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">{m.profile_sessions_heading()}</span>
|
||||
{#if sessions.length > 0}
|
||||
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{sessions.length} device{sessions.length !== 1 ? 's' : ''}</span>
|
||||
{/if}
|
||||
<svg class={cn(chevronClass, expanded === 'sessions' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded === 'sessions'}
|
||||
<div class="bg-(--color-surface-3)/30">
|
||||
<p class="px-5 pt-4 pb-2 text-xs text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
||||
|
||||
{#if revokeError}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||||
<div class="mx-5 mb-3 rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||||
{/if}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||
<p class="px-5 pb-5 text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
<ul class="divide-y divide-(--color-border) pb-2">
|
||||
{#each sessions as session (session.id)}
|
||||
<li class={cn(
|
||||
'flex items-start justify-between gap-3 rounded-lg px-4 py-3',
|
||||
session.is_current
|
||||
? 'bg-(--color-brand)/10 border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3)/50 border border-(--color-border)/50'
|
||||
)}>
|
||||
<li class="flex items-start justify-between gap-3 px-5 py-3">
|
||||
<div class="min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||
@@ -743,115 +1046,31 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── In-app notifications ──────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">In-app notifications</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if notifyNewChapters}
|
||||
You'll receive a notification when new chapters are added to books in your library.
|
||||
{:else}
|
||||
In-app new-chapter notifications are disabled.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleNotifyNewChapters}
|
||||
disabled={notifyNewChaptersSaving}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={notifyNewChapters}
|
||||
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
||||
>
|
||||
<span class={cn(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
notifyNewChapters ? 'translate-x-6' : 'translate-x-1'
|
||||
)}></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
||||
{#if pushState !== 'unsupported'}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if pushState === 'subscribed'}
|
||||
You'll receive a push notification when new chapters are added to books in your library.
|
||||
{:else if pushState === 'denied'}
|
||||
Notifications are blocked by your browser. Change the permission in your browser settings.
|
||||
{:else}
|
||||
Get notified when new chapters arrive for books in your library.
|
||||
{/if}
|
||||
</p>
|
||||
{#if pushError}
|
||||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
{#if pushState === 'subscribed'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={unsubscribePush}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
Turn off
|
||||
</button>
|
||||
{:else if pushState === 'denied'}
|
||||
<span class="text-xs text-(--color-muted) italic">Blocked</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={subscribePush}
|
||||
disabled={pushState === 'loading'}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{#if pushState === 'loading'}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Turn on
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<div class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
|
||||
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
|
||||
onclick={() => { toggle('danger'); deleteConfirmText = ''; deleteError = ''; }}
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-red-500/5 transition-colors text-left"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-400">Danger zone</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-red-400">Danger zone</span>
|
||||
<svg class={cn('w-4 h-4 text-red-400/60 shrink-0 transition-transform duration-200', expanded === 'danger' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if deleteConfirmOpen}
|
||||
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
|
||||
{#if expanded === 'danger'}
|
||||
<div class="px-5 pb-6 space-y-4 border-t border-red-500/20">
|
||||
<div class="pt-4">
|
||||
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">
|
||||
@@ -892,118 +1111,6 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if} <!-- end profile tab -->
|
||||
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Reading overview -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted },
|
||||
{ label: 'Reading', value: data.stats.booksReading },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Streak + rating -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
|
||||
</div>
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-4">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top genres -->
|
||||
{#if data.stats.topGenres.length > 0}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class={cn(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium',
|
||||
i === 0
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
|
||||
)}>
|
||||
{genre}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.stats.booksDropped > 0}
|
||||
<p class="text-xs text-(--color-muted) text-center">
|
||||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||||
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'history'}
|
||||
<div class="space-y-2">
|
||||
{#if data.history.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted)">
|
||||
<p class="text-sm">No reading history yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each data.history as item}
|
||||
<a
|
||||
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
|
||||
>
|
||||
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
|
||||
</div>
|
||||
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||||
{#if item.updated}
|
||||
{(() => {
|
||||
const ms = Date.now() - new Date(item.updated).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
})()}
|
||||
{/if}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user