diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index 38fbd96..9e563c8 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -79,6 +79,9 @@ class AudioStore { /** Epoch ms when sleep timer should fire. 0 = off. */ sleepUntil = $state(0); + /** When true, pause after the current chapter ends instead of navigating. */ + sleepAfterChapter = $state(false); + // ── Auto-next ──────────────────────────────────────────────────────────── /** * When true, navigates to the next chapter when the current one ends diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index b1361b3..2fb465a 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -699,16 +699,22 @@ }); function cycleSleepTimer() { - if (!audioStore.sleepUntil) { - // Start at first option (15 min) + // Currently: no timer active at all + if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) { + audioStore.sleepAfterChapter = true; + return; + } + // Currently: end-of-chapter mode — move to 15m + if (audioStore.sleepAfterChapter) { + audioStore.sleepAfterChapter = false; audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000; return; } + // Currently: timed mode — cycle to next or turn off const remaining = audioStore.sleepUntil - Date.now(); const currentMin = Math.round(remaining / 60000); - const idx = SLEEP_OPTIONS.findIndex(m => m >= currentMin); + const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin); if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) { - // Was at max or past last — turn off audioStore.sleepUntil = 0; } else { audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000; @@ -933,14 +939,20 @@ + +
+ + +
+ + {#if activeTab === 'discover'} {#if deckEmpty}
@@ -606,4 +641,58 @@ Swipe or tap buttons · Tap card for details

{/if} + {/if} + + {#if activeTab === 'history'} +
+ {#if !votedBooks.length} +

No votes yet — start swiping!

+ {:else} + {#each votedBooks as v (v.slug)} + {@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'} + {@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'} +
+ + {#if v.book?.cover} + + {:else} +
+ {/if} + + +
+ + {v.book?.title ?? v.slug} + + {#if v.book?.author} +

{v.book.author}

+ {/if} + {actionLabel} +
+ + + +
+ {/each} + + + {/if} +
+ {/if}
diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index c4ccea7..066f288 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase'; +import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase'; import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; @@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ locals }) => { let sessions: Awaited> = []; let email: string | null = null; let polarCustomerId: string | null = null; + let stats: Awaited> | null = null; // Fetch avatar — MinIO first, fall back to OAuth provider picture let avatarUrl: string | null = null; @@ -25,9 +26,12 @@ export const load: PageServerLoad = async ({ locals }) => { } try { - sessions = await listUserSessions(locals.user.id); + [sessions, stats] = await Promise.all([ + listUserSessions(locals.user.id), + getUserStats(locals.sessionId, locals.user.id) + ]); } catch (e) { - log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) }); + log.warn('profile', 'load failed (non-fatal)', { err: String(e) }); } return { @@ -35,6 +39,11 @@ export const load: PageServerLoad = async ({ locals }) => { avatarUrl, email, polarCustomerId, + stats: stats ?? { + totalChaptersRead: 0, booksReading: 0, booksCompleted: 0, + booksPlanToRead: 0, booksDropped: 0, topGenres: [], + avgRatingGiven: 0, streak: 0 + }, sessions: sessions.map((s) => ({ id: s.id, user_agent: s.user_agent, diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 1cc7f8c..c6daa5b 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -184,6 +184,9 @@ }, 800) as unknown as number; }); + // ── Tab ────────────────────────────────────────────────────────────────────── + let activeTab = $state<'profile' | 'stats'>('profile'); + // ── Sessions ───────────────────────────────────────────────────────────────── type Session = { id: string; @@ -317,6 +320,23 @@ + +
+ {#each (['profile', 'stats'] as const) as tab} + + {/each} +
+ + {#if activeTab === 'profile'} {#if !data.isPro}
@@ -565,5 +585,77 @@ {/if}
+ {/if} + + {#if activeTab === 'stats'} +
+ + +
+

Reading Overview

+
+ {#each [ + { label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' }, + { label: 'Completed', value: data.stats.booksCompleted, icon: '✅' }, + { label: 'Reading', value: data.stats.booksReading, icon: '📚' }, + { label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' }, + ] as stat} +
+

{stat.value}

+

{stat.label}

+
+ {/each} +
+
+ + +
+

Activity

+
+
+
🔥
+
+

{data.stats.streak}

+

day streak

+
+
+
+
+
+

+ {data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'} +

+

avg rating given

+
+
+
+
+ + + {#if data.stats.topGenres.length > 0} +
+

Favourite Genres

+
+ {#each data.stats.topGenres as genre, i} + + {#if i === 0}🏆{/if} + {genre} + + {/each} +
+
+ {/if} + + + {#if data.stats.booksDropped > 0} +

+ {data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} — + revisit your library +

+ {/if} + +
+ {/if}