Compare commits

...

7 Commits

Author SHA1 Message Date
Admin
28fee7aee3 feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated
sorting (only changes on metadata writes) with chapters_idx sorted
by -created, so the section now reflects actual chapter activity.

Add per-section show/hide toggles on the home page, persisted in
localStorage via Svelte 5 $state. Each section header gets a small
hide button; hidden sections appear as restore chips above the footer.
Toggleable: Recently Updated, Browse by Genre, From Following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:09:48 +05:00
Admin
a888d9a0f5 fix: clean up book detail mobile layout + make genre tags linkable
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.

- Split mobile CTAs into two clear rows:
  Row 1: primary read button(s) only (Continue / Start from ch.1)
  Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
  present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line

Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
2026-04-03 21:34:38 +05:00
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

- Add settingsDirty flag, set via setTimeout(0) after initial apply so
  the save effect is blocked for the first load and only runs on real
  user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
  validation as a defensive backstop
2026-04-03 21:07:01 +05:00
Admin
24d73cb730 fix: add device_fingerprint to PB schema + fix homelab Redis routing
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m33s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
OAuth login was silently failing: upsertUserSession() queried the
device_fingerprint column which didn't exist in the user_sessions
collection, PocketBase returned 400, the fallback authSessionId was
never written to the DB, and isSessionRevoked() immediately revoked
the cookie on first load after the OAuth redirect.

- scripts/pb-init-v3.sh: add device_fingerprint text field to the
  user_sessions create block (new installs) and add an idempotent
  add_field migration line (existing installs)

Audio jobs were stuck pending because the homelab runner was
connecting to its own local Redis instead of the prod VPS Redis.

- homelab/docker-compose.yml: change hardcoded REDIS_ADDR=redis:6379
  to ${REDIS_ADDR} so Doppler injects rediss://redis.libnovel.cc:6380
  (the Caddy TLS proxy that bridges the homelab runner to prod Redis)
2026-04-03 20:37:10 +05:00
Admin
19aeb90403 perf: cache home stats + ratings, fix discover card pop animation
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 48s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / backend (push) Successful in 2m52s
Release / Docker / ui (push) Successful in 2m4s
Release / Gitea Release (push) Successful in 21s
Cache home stats (10 min) and recently added books (5 min) to avoid
hitting PocketBase on every homepage load. Cache all ratings for
discovery ranking (5 min) with invalidation on setBookRating.
invalidateBooksCache now clears all related keys atomically.

Fix discover card pop-to-full-size bug: new card now transitions from
scale(0.95) to scale(1.0) matching its back-card position, instead of
snapping to full size instantly after each swipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:15:58 +05:00
Admin
06d4a7bfd4 feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s
**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq

**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts

**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic

**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 07:26:54 +05:00
Admin
73a92ccf8f fix: deduplicate sessions with device fingerprint upsert
All checks were successful
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / ui (push) Successful in 2m8s
Release / Docker / backend (push) Successful in 3m17s
Release / Docker / runner (push) Successful in 3m13s
Release / Gitea Release (push) Successful in 12s
OAuth callbacks were creating a new session record on every login from
the same device because user-agent/IP were hardcoded as empty strings,
producing a pile-up of 6+ identical 'Unknown browser' sessions.

- Add upsertUserSession(): looks up existing session by user_id +
  device_fingerprint (SHA-256 of ua::ip, first 16 hex chars); reuses
  and touches it (returning the same authSessionId) if found, creates
  a new record otherwise
- Add device_fingerprint field to UserSession interface
- Fix OAuth callback to read real user-agent/IP from request headers
  (they are available in RequestHandler via request.headers)
- Switch both OAuth and password login to upsertUserSession so the
  returned authSessionId is used for the auth token
- Extend pruneStaleUserSessions to also cap sessions at 10 per user
- Keep createUserSession as a deprecated shim for gradual migration
2026-04-02 22:22:17 +05:00
17 changed files with 818 additions and 172 deletions

View File

@@ -63,7 +63,7 @@ services:
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis ─────────────────────────────────────────────────────
REDIS_ADDR: "redis:6379"
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
KOKORO_URL: "http://kokoro-fastapi:8880"

View File

@@ -190,14 +190,15 @@ create "app_users" '{
{"name":"oauth_id", "type":"text"}
]}'
create "user_sessions" '{
create "user_sessions" '{
"name":"user_sessions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"session_id","type":"text","required":true},
{"name":"user_agent","type":"text"},
{"name":"ip", "type":"text"},
{"name":"created_at","type":"text"},
{"name":"last_seen", "type":"text"}
{"name":"user_id", "type":"text","required":true},
{"name":"session_id", "type":"text","required":true},
{"name":"user_agent", "type":"text"},
{"name":"ip", "type":"text"},
{"name":"device_fingerprint", "type":"text"},
{"name":"created_at", "type":"text"},
{"name":"last_seen", "type":"text"}
]}'
create "user_library" '{
@@ -291,5 +292,6 @@ add_field "app_users" "oauth_id" "text"
add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
log "done"

View File

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

View File

@@ -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 @@
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepUntil}
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep

View File

@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
const BOOKS_CACHE_KEY = 'books:all';
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
const RATINGS_CACHE_KEY = 'book_ratings:all';
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
async function getAllRatings(): Promise<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
return ratings;
}
export async function listBooks(): Promise<Book[]> {
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (cached) {
@@ -282,7 +296,12 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
/** Invalidate the books cache (call after a book is created/updated/deleted). */
export async function invalidateBooksCache(): Promise<void> {
await cache.invalidate(BOOKS_CACHE_KEY);
await Promise.all([
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*'),
cache.invalidatePattern('books:recently-updated:*')
]);
}
export async function getBook(slug: string): Promise<Book | null> {
@@ -290,7 +309,48 @@ export async function getBook(slug: string): Promise<Book | null> {
}
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
await cache.set(key, books, 5 * 60);
return books;
}
/**
* Books with the most recently added chapters, ordered by chapter insertion time.
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
* This correctly reflects actual chapter activity, unlike meta_updated on books.
*/
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
const key = `books:recently-updated:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
// Fetch enough recent chapter rows to find `limit` distinct books
const rows = await listN<{ slug: string; created: string }>(
'chapters_idx', limit * 25, '', '-created'
);
const seen = new Set<string>();
const slugs: string[] = [];
for (const row of rows) {
if (!seen.has(row.slug)) {
seen.add(row.slug);
slugs.push(row.slug);
if (slugs.length >= limit) break;
}
}
if (!slugs.length) return [];
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
await cache.set(key, ordered, 5 * 60);
return ordered;
}
export interface HomeStats {
@@ -299,11 +359,19 @@ export interface HomeStats {
}
export async function getHomeStats(): Promise<HomeStats> {
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
if (cached) return cached;
const [totalBooks, totalChapters] = await Promise.all([
countCollection('books'),
countCollection('chapters_idx')
]);
return { totalBooks, totalChapters };
const stats = { totalBooks, totalChapters };
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
return stats;
}
export async function invalidateHomeStatsCache(): Promise<void> {
await cache.invalidate(HOME_STATS_CACHE_KEY);
}
// ─── Chapter index ────────────────────────────────────────────────────────────
@@ -542,7 +610,7 @@ export async function unsaveBook(
// ─── Users ────────────────────────────────────────────────────────────────────
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
@@ -1003,12 +1071,79 @@ export interface UserSession {
session_id: string; // the auth session ID embedded in the token
user_agent: string;
ip: string;
device_fingerprint: string;
created_at: string;
last_seen: string;
}
/**
* Create a new session record on login. Returns the record ID.
* Generate a short device fingerprint from user-agent + IP.
* SHA-256 of the concatenation, first 16 hex chars.
*/
function deviceFingerprint(userAgent: string, ip: string): string {
return createHash('sha256')
.update(`${userAgent}::${ip}`)
.digest('hex')
.slice(0, 16);
}
/**
* Upsert a session record on login.
* - If a session already exists for this user + device fingerprint, touch it and
* return the existing authSessionId (so the caller can reuse the same token).
* - Otherwise create a new record.
* Returns `{ authSessionId, recordId }`.
*/
export async function upsertUserSession(
userId: string,
authSessionId: string,
userAgent: string,
ip: string
): Promise<{ authSessionId: string; recordId: string }> {
const fp = deviceFingerprint(userAgent, ip);
// Look for an existing session from the same device
const existing = await listOne<UserSession>(
'user_sessions',
`user_id="${userId}" && device_fingerprint="${fp}"`
);
if (existing) {
// Touch last_seen and return the existing authSessionId
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen: new Date().toISOString() })
}).catch(() => {});
return { authSessionId: existing.session_id, recordId: existing.id };
}
// Create a new session record
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
device_fingerprint: fp,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale/excess sessions in the background
pruneStaleUserSessions(userId).catch(() => {});
return { authSessionId, recordId: rec.id };
}
/**
* @deprecated Use upsertUserSession instead.
* Kept temporarily so callers can be migrated incrementally.
*/
export async function createUserSession(
userId: string,
@@ -1016,24 +1151,8 @@ export async function createUserSession(
userAgent: string,
ip: string
): Promise<string> {
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
pruneStaleUserSessions(userId).catch(() => {});
return rec.id;
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
return recordId;
}
/**
@@ -1070,20 +1189,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
}
/**
* Delete sessions for a user that haven't been seen in the last `days` days.
* Delete sessions for a user that haven't been seen in the last `days` days,
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
* Called on login so the list self-cleans without a separate cron job.
*/
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const stale = await listAll<UserSession>(
'user_sessions',
`user_id="${userId}" && last_seen<"${cutoff}"`
);
if (stale.length === 0) return;
async function pruneStaleUserSessions(
userId: string,
days = 30,
maxSessions = 10
): Promise<void> {
const token = await getToken();
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
const remaining = all.filter((s) => !toDelete.has(s.id));
if (remaining.length > maxSessions) {
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
}
if (toDelete.size === 0) return;
await Promise.all(
stale.map((s) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
[...toDelete].map((id) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
@@ -1781,6 +1917,7 @@ export async function setBookRating(
} else {
await pbPost('/api/collections/book_ratings/records', payload);
}
await cache.invalidate(RATINGS_CACHE_KEY);
}
// ─── Shelves ───────────────────────────────────────────────────────────────────
@@ -1847,11 +1984,167 @@ export async function getBooksForDiscovery(
if (sf.length >= 3) candidates = sf;
}
// Fisher-Yates shuffle
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
// appear — they're just pushed further back via a stable sort before shuffle.
const ratingRows = await getAllRatings();
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
cur.sum += r.rating;
cur.count += 1;
ratingMap.set(r.slug, cur);
}
const avgRating = (slug: string) => {
const e = ratingMap.get(slug);
return e && e.count > 0 ? e.sum / e.count : 0;
};
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 010
let start = 0;
while (start < candidates.length) {
let end = start + 1;
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
for (let i = end - 1; i > start; i--) {
const j = start + Math.floor(Math.random() * (i - start + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
start = end;
}
return candidates.slice(0, 50);
}
// ─── Discovery history ─────────────────────────────────────────────────────────
export interface VotedBook {
slug: string;
action: DiscoveryVote['action'];
votedAt: string;
book?: Book;
}
export async function getVotedBooks(
sessionId: string,
userId?: string
): Promise<VotedBook[]> {
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
'discovery_votes',
discoveryFilter(sessionId, userId),
'-created'
).catch(() => []);
if (!votes.length) return [];
const slugs = [...new Set(votes.map((v) => v.slug))];
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
const bookMap = new Map(books.map((b) => [b.slug, b]));
return votes.map((v) => ({
slug: v.slug,
action: v.action,
votedAt: v.created,
book: bookMap.get(v.slug)
}));
}
export async function undoDiscoveryVote(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
}
// ─── User stats ────────────────────────────────────────────────────────────────
export interface UserStats {
totalChaptersRead: number;
booksReading: number;
booksCompleted: number;
booksPlanToRead: number;
booksDropped: number;
topGenres: string[]; // top 3 by frequency
avgRatingGiven: number; // 0 if no ratings
streak: number; // consecutive days with progress
}
export async function getUserStats(
sessionId: string,
userId?: string
): Promise<UserStats> {
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
listAll<BookRating>('book_ratings', filter).catch(() => []),
listBooks().catch(() => [] as Book[])
]);
// shelf counts
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
for (const r of libraryRows) {
const s = r.shelf || 'reading';
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
}
// top genres from books in progress/library
const libSlugs = new Set(libraryRows.map((r) => r.slug));
const progSlugs = new Set(progressRows.map((r) => r.slug));
const allSlugs = new Set([...libSlugs, ...progSlugs]);
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
const genreFreq = new Map<string, number>();
for (const slug of allSlugs) {
const book = bookMap.get(slug);
if (!book) continue;
for (const g of parseGenresLocal(book.genres)) {
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
}
}
const topGenres = [...genreFreq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
// avg rating given
const avgRatingGiven =
ratingRows.length > 0
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
: 0;
// reading streak: count consecutive calendar days (UTC) with a progress update
const days = new Set(
progressRows
.filter((r) => r.updated)
.map((r) => r.updated.slice(0, 10))
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
if (days.has(d.toISOString().slice(0, 10))) streak++;
else if (i > 0) break; // gap — stop
}
return {
totalChaptersRead: progressRows.length,
booksReading: shelfCounts.reading,
booksCompleted: shelfCounts.completed,
booksPlanToRead: shelfCounts.plan_to_read,
booksDropped: shelfCounts.dropped,
topGenres,
avgRatingGiven,
streak
};
}

View File

@@ -88,6 +88,7 @@
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
let settingsDirty = false; // true only after the first apply completes
$effect(() => {
if (data.settings) {
if (!settingsApplied) {
@@ -100,6 +101,9 @@
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);
}
});
@@ -114,8 +118,9 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
if (!settingsDirty) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {
@@ -289,6 +294,12 @@
onended={() => {
audioStore.isPlaying = false;
saveAudioTime();
// If sleep-after-chapter is set, just pause instead of navigating
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
// Don't navigate just let it end. Audio is already stopped (ended).
return;
}
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
// Capture values synchronously before any async work — the AudioPlayer
// component will unmount during navigation, but we've already read what

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import {
getBooksBySlugs,
recentlyAddedBooks,
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => {
try {
[recentBooks, progressList, stats] = await Promise.all([
recentlyAddedBooks(8),
recentlyUpdatedBooks(8),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);

View File

@@ -1,9 +1,47 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
const SECTIONS_KEY = 'home_sections_v1';
const SECTION_LABELS: Record<SectionId, string> = {
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
};
function loadHidden(): Set<SectionId> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(SECTIONS_KEY);
if (raw) return new Set(JSON.parse(raw) as SectionId[]);
} catch { /* ignore */ }
return new Set();
}
let hidden = $state<Set<SectionId>>(loadHidden());
function hide(id: SectionId) {
hidden = new Set([...hidden, id]);
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...hidden]));
}
function restore(id: SectionId) {
const next = new Set(hidden);
next.delete(id);
hidden = next;
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
}
const hiddenList = $derived(
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
);
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
@@ -121,10 +159,19 @@
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} 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-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
@@ -135,13 +182,22 @@
{/each}
</div>
</section>
{/if}
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} 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 dedupedRecent as { book, count }}
@@ -182,10 +238,16 @@
{/if}
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#if data.subscriptionFeed.length > 0}
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} 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 class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
@@ -221,6 +283,23 @@
</div>
{/if}
<!-- ── Hidden sections restore ───────────────────────────────────────────────── -->
{#if hiddenList.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2">
<span class="text-xs text-(--color-muted)">Hidden:</span>
{#each hiddenList as id}
<button type="button" onclick={() => restore(id)}
class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{SECTION_LABELS[id]}
</button>
{/each}
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { loginUser, mergeSessionProgress, upsertUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
@@ -48,16 +48,20 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
);
const authSessionId = randomBytes(16).toString('hex');
const candidateSessionId = randomBytes(16).toString('hex');
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
);
let authSessionId = candidateSessionId;
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (e) {
log.warn('api/auth/login', 'upsertUserSession failed (non-fatal)', { err: String(e) });
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook } from '$lib/server/pocketbase';
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
type Action = (typeof VALID_ACTIONS)[number];
@@ -22,9 +22,16 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ locals }) => {
// DELETE /api/discover/vote → clear all (deck reset)
// DELETE /api/discover/vote?slug=... → undo single vote
export const DELETE: RequestHandler = async ({ url, locals }) => {
const slug = url.searchParams.get('slug');
try {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
if (slug) {
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
} else {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
}
} catch {
error(500, 'Failed to clear votes');
}

View File

@@ -43,27 +43,27 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
// theme is optional — if provided it must be a known value
// theme is optional — if provided (and non-empty) it must be a known value
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
if (body.theme !== undefined && body.theme !== '' && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
// locale is optional — if provided (and non-empty) it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
if (body.locale !== undefined && body.locale !== '' && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
// fontFamily is optional — if provided (and non-empty) it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
if (body.fontFamily !== undefined && body.fontFamily !== '' && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}

View File

@@ -25,7 +25,7 @@ import {
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { upsertUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
// ─── Handler ──────────────────────────────────────────────────────────────────
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
export const GET: RequestHandler = async ({ params, url, cookies, locals, request }) => {
const provider = params.provider as Provider;
if (provider !== 'google' && provider !== 'github') {
error(404, 'Unknown OAuth provider');
@@ -226,21 +226,19 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// ── Create session + auth cookie ──────────────────────────────────────────
// ── Create / reuse session + auth cookie ─────────────────────────────────
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
const candidateSessionId = randomBytes(16).toString('hex');
let authSessionId: string;
// Reuse existing session if the user is already logged in as the same user
if (locals.user?.id === user.id && locals.user?.authSessionId) {
authSessionId = locals.user.authSessionId;
// Just touch the existing session to update last_seen
touchUserSession(authSessionId).catch(() => {});
} else {
authSessionId = randomBytes(16).toString('hex');
const userAgent = ''; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (err) {
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
authSessionId = candidateSessionId;
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -234,7 +234,10 @@
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
<a
href="/catalogue?genre={encodeURIComponent(genre)}"
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
>{genre}</a>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
@@ -348,65 +351,82 @@
</div>
<!-- CTA buttons — mobile only -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) 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="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<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-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<div class="flex sm:hidden flex-col gap-2 mt-3">
<!-- Row 1: primary read button(s) -->
<div class="flex gap-2">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'flex-1 bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
</div>
<!-- Row 2: bookmark + shelf + stars -->
<div class="flex items-center gap-2 flex-wrap">
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-9 h-9 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<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-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</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="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
{#if saved}
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer flex-shrink-0"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
@@ -414,20 +434,6 @@
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { getBooksForDiscovery } from '$lib/server/pocketbase';
import { getBooksForDiscovery, getVotedBooks } from '$lib/server/pocketbase';
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals, url }) => {
@@ -9,6 +9,9 @@ export const load: PageServerLoad = async ({ locals, url }) => {
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
}
const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []);
return { books };
const [books, votedBooks] = await Promise.all([
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
]);
return { books, votedBooks };
};

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { Book } from '$lib/server/pocketbase';
import type { Book, VotedBook } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
@@ -83,6 +83,20 @@
let transitioning = $state(false);
let showPreview = $state(false);
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
let activeTab = $state<'discover' | 'history'>('discover');
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
// Keep in sync if server data refreshes
$effect(() => {
votedBooks = data.votedBooks ?? [];
});
async function undoVote(slug: string) {
// Optimistic update
votedBooks = votedBooks.filter((v) => v.slug !== slug);
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
}
let startX = 0, startY = 0, hasMoved = false;
@@ -116,7 +130,43 @@
let cardEl = $state<HTMLDivElement | null>(null);
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
let cardEntering = $state(false);
let entryTransition = $state(false);
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
function startEntryAnimation() {
if (entryCleanup) clearTimeout(entryCleanup);
cardEntering = true;
entryTransition = true;
requestAnimationFrame(() => {
cardEntering = false;
entryCleanup = setTimeout(() => { entryTransition = false; }, 400);
});
}
function cancelEntryAnimation() {
if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; }
cardEntering = false;
entryTransition = false;
}
const activeTransform = $derived(
cardEntering
? 'scale(0.95) translateY(13px)'
: `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)`
);
const activeTransition = $derived(
isDragging
? 'none'
: (transitioning || entryTransition)
? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
: 'none'
);
function onPointerDown(e: PointerEvent) {
cancelEntryAnimation();
if (animating || !currentBook) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startX = e.clientX;
@@ -202,13 +252,15 @@
if (action === 'read_now') {
goto(`/books/${book.slug}`);
} else {
startEntryAnimation();
}
}
async function resetDeck() {
await fetch('/api/discover/vote', { method: 'DELETE' });
votedBooks = [];
idx = 0;
// Reload page to get fresh server data
window.location.reload();
}
</script>
@@ -389,6 +441,27 @@
</button>
</div>
<!-- Tab switcher -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
<button
type="button"
onclick={() => (activeTab = 'discover')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
History {#if votedBooks.length}({votedBooks.length}){/if}
</button>
</div>
{#if activeTab === 'discover'}
{#if deckEmpty}
<!-- Empty state -->
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
@@ -458,8 +531,8 @@
bind:this={cardEl}
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style="
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
transform: {activeTransform};
transition: {activeTransition};
touch-action: none;
"
onpointerdown={onPointerDown}
@@ -606,4 +679,58 @@
Swipe or tap buttons · Tap card for details
</p>
{/if}
{/if}
{#if activeTab === 'history'}
<div class="w-full max-w-sm space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{: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'}
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
<!-- Cover thumbnail -->
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
{/if}
<!-- Info -->
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<!-- Undo button -->
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
Clear all history
</button>
{/if}
</div>
{/if}
</div>

View File

@@ -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<ReturnType<typeof listUserSessions>> = [];
let email: string | null = null;
let polarCustomerId: string | null = null;
let stats: Awaited<ReturnType<typeof getUserStats>> | 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,

View File

@@ -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 @@
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
{#each (['profile', 'stats'] as const) as tab}
<button
type="button"
onclick={() => (activeTab = tab)}
class="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)'}"
>
{tab === 'profile' ? 'Profile' : 'Stats'}
</button>
{/each}
</div>
{#if activeTab === 'profile'}
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
@@ -565,5 +585,77 @@
</ul>
{/if}
</section>
{/if}
{#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, 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}
<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="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted)">day streak</p>
</div>
</div>
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0"></div>
<div>
<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)">avg rating given</p>
</div>
</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="flex items-center gap-1.5 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)'}">
{#if i === 0}<span class="text-xs">🏆</span>{/if}
{genre}
</span>
{/each}
</div>
</section>
{/if}
<!-- Dropped books (only if any) -->
{#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}
</div>