Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fee7aee3 | ||
|
|
a888d9a0f5 | ||
|
|
ac7b686fba | ||
|
|
24d73cb730 | ||
|
|
19aeb90403 | ||
|
|
06d4a7bfd4 | ||
|
|
73a92ccf8f |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); // 0–10
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user