Compare commits
5 Commits
v2.5.43
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fee7aee3 | ||
|
|
a888d9a0f5 | ||
|
|
ac7b686fba | ||
|
|
24d73cb730 | ||
|
|
19aeb90403 |
@@ -63,7 +63,7 @@ services:
|
|||||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||||
|
|
||||||
# ── Asynq / Redis ─────────────────────────────────────────────────────
|
# ── Asynq / Redis ─────────────────────────────────────────────────────
|
||||||
REDIS_ADDR: "redis:6379"
|
REDIS_ADDR: "${REDIS_ADDR}"
|
||||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||||
|
|
||||||
KOKORO_URL: "http://kokoro-fastapi:8880"
|
KOKORO_URL: "http://kokoro-fastapi:8880"
|
||||||
|
|||||||
@@ -190,14 +190,15 @@ create "app_users" '{
|
|||||||
{"name":"oauth_id", "type":"text"}
|
{"name":"oauth_id", "type":"text"}
|
||||||
]}'
|
]}'
|
||||||
|
|
||||||
create "user_sessions" '{
|
create "user_sessions" '{
|
||||||
"name":"user_sessions","type":"base","fields":[
|
"name":"user_sessions","type":"base","fields":[
|
||||||
{"name":"user_id", "type":"text","required":true},
|
{"name":"user_id", "type":"text","required":true},
|
||||||
{"name":"session_id","type":"text","required":true},
|
{"name":"session_id", "type":"text","required":true},
|
||||||
{"name":"user_agent","type":"text"},
|
{"name":"user_agent", "type":"text"},
|
||||||
{"name":"ip", "type":"text"},
|
{"name":"ip", "type":"text"},
|
||||||
{"name":"created_at","type":"text"},
|
{"name":"device_fingerprint", "type":"text"},
|
||||||
{"name":"last_seen", "type":"text"}
|
{"name":"created_at", "type":"text"},
|
||||||
|
{"name":"last_seen", "type":"text"}
|
||||||
]}'
|
]}'
|
||||||
|
|
||||||
create "user_library" '{
|
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_customer_id" "text"
|
||||||
add_field "app_users" "polar_subscription_id" "text"
|
add_field "app_users" "polar_subscription_id" "text"
|
||||||
add_field "user_library" "shelf" "text"
|
add_field "user_library" "shelf" "text"
|
||||||
|
add_field "user_sessions" "device_fingerprint" "text"
|
||||||
|
|
||||||
log "done"
|
log "done"
|
||||||
|
|||||||
@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
|
|||||||
const BOOKS_CACHE_KEY = 'books:all';
|
const BOOKS_CACHE_KEY = 'books:all';
|
||||||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
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[]> {
|
export async function listBooks(): Promise<Book[]> {
|
||||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||||
if (cached) {
|
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). */
|
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||||
export async function invalidateBooksCache(): Promise<void> {
|
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> {
|
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[]> {
|
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 {
|
export interface HomeStats {
|
||||||
@@ -299,11 +359,19 @@ export interface HomeStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getHomeStats(): Promise<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([
|
const [totalBooks, totalChapters] = await Promise.all([
|
||||||
countCollection('books'),
|
countCollection('books'),
|
||||||
countCollection('chapters_idx')
|
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 ────────────────────────────────────────────────────────────
|
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||||
@@ -1849,6 +1917,7 @@ export async function setBookRating(
|
|||||||
} else {
|
} else {
|
||||||
await pbPost('/api/collections/book_ratings/records', payload);
|
await pbPost('/api/collections/book_ratings/records', payload);
|
||||||
}
|
}
|
||||||
|
await cache.invalidate(RATINGS_CACHE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1918,7 +1987,7 @@ export async function getBooksForDiscovery(
|
|||||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
// 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
|
// 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.
|
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||||
const ratingRows = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
const ratingRows = await getAllRatings();
|
||||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||||
for (const r of ratingRows) {
|
for (const r of ratingRows) {
|
||||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
// Apply persisted settings once on mount (server-loaded data).
|
// Apply persisted settings once on mount (server-loaded data).
|
||||||
// Use a derived to react to future invalidateAll() re-loads too.
|
// Use a derived to react to future invalidateAll() re-loads too.
|
||||||
let settingsApplied = false;
|
let settingsApplied = false;
|
||||||
|
let settingsDirty = false; // true only after the first apply completes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (data.settings) {
|
if (data.settings) {
|
||||||
if (!settingsApplied) {
|
if (!settingsApplied) {
|
||||||
@@ -100,6 +101,9 @@
|
|||||||
currentTheme = data.settings.theme ?? 'amber';
|
currentTheme = data.settings.theme ?? 'amber';
|
||||||
currentFontFamily = data.settings.fontFamily ?? 'system';
|
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||||
currentFontSize = data.settings.fontSize ?? 1.0;
|
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 fontFamily = currentFontFamily;
|
||||||
const fontSize = currentFontSize;
|
const fontSize = currentFontSize;
|
||||||
|
|
||||||
// Skip saving until settings have been applied from the server
|
// Skip saving until settings have been applied from the server AND
|
||||||
if (!settingsApplied) return;
|
// at least one user-driven change has occurred after that.
|
||||||
|
if (!settingsDirty) return;
|
||||||
|
|
||||||
clearTimeout(settingsSaveTimer);
|
clearTimeout(settingsSaveTimer);
|
||||||
settingsSaveTimer = setTimeout(() => {
|
settingsSaveTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import {
|
import {
|
||||||
getBooksBySlugs,
|
getBooksBySlugs,
|
||||||
recentlyAddedBooks,
|
recentlyUpdatedBooks,
|
||||||
allProgress,
|
allProgress,
|
||||||
getHomeStats,
|
getHomeStats,
|
||||||
getSubscriptionFeed
|
getSubscriptionFeed
|
||||||
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
[recentBooks, progressList, stats] = await Promise.all([
|
[recentBooks, progressList, stats] = await Promise.all([
|
||||||
recentlyAddedBooks(8),
|
recentlyUpdatedBooks(8),
|
||||||
allProgress(locals.sessionId, locals.user?.id),
|
allProgress(locals.sessionId, locals.user?.id),
|
||||||
getHomeStats()
|
getHomeStats()
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,9 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
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[] {
|
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||||
if (!genres) return [];
|
if (!genres) return [];
|
||||||
if (Array.isArray(genres)) return genres;
|
if (Array.isArray(genres)) return genres;
|
||||||
@@ -121,10 +159,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
||||||
|
{#if !hidden.has('browse-genre')}
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
|
<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>
|
||||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
||||||
{#each GENRES as genre}
|
{#each GENRES as genre}
|
||||||
@@ -135,13 +182,22 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
|
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
|
||||||
{#if dedupedRecent.length > 0}
|
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
<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>
|
||||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||||
{#each dedupedRecent as { book, count }}
|
{#each dedupedRecent as { book, count }}
|
||||||
@@ -182,10 +238,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── From Following ────────────────────────────────────────────────────────── -->
|
<!-- ── From Following ────────────────────────────────────────────────────────── -->
|
||||||
{#if data.subscriptionFeed.length > 0}
|
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
<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>
|
||||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||||
@@ -221,6 +283,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 ──────────────────────────────────────────────────────────── -->
|
<!-- ── 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)">
|
<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>
|
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
|
||||||
|
|||||||
@@ -43,27 +43,27 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
|||||||
error(400, 'Invalid body — expected { autoNext, voice, speed }');
|
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'];
|
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(', ')}`);
|
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'];
|
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(', ')}`);
|
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'];
|
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(', ')}`);
|
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];
|
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(', ')}`);
|
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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}
|
{/if}
|
||||||
{#each genres as genre}
|
{#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}
|
{/each}
|
||||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
{#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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- CTA buttons — mobile only -->
|
<!-- CTA buttons — mobile only -->
|
||||||
<div class="flex sm:hidden gap-2 items-center">
|
<div class="flex sm:hidden flex-col gap-2 mt-3">
|
||||||
{#if data.lastChapter}
|
<!-- Row 1: primary read button(s) -->
|
||||||
<a
|
<div class="flex gap-2">
|
||||||
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
{#if 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"
|
<a
|
||||||
>
|
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
||||||
{m.book_detail_continue_ch({ n: String(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"
|
||||||
</a>
|
>
|
||||||
{/if}
|
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||||
{#if chapterList.length > 0}
|
</a>
|
||||||
<a
|
{/if}
|
||||||
href="/books/{book.slug}/chapters/1"
|
{#if chapterList.length > 0}
|
||||||
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
|
<a
|
||||||
{data.lastChapter
|
href="/books/{book.slug}/chapters/1"
|
||||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
class="text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
|
||||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
{data.lastChapter
|
||||||
>
|
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
: 'flex-1 bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||||
</a>
|
>
|
||||||
{/if}
|
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||||
{#if !data.isLoggedIn}
|
</a>
|
||||||
<a
|
{/if}
|
||||||
href="/login"
|
</div>
|
||||||
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"
|
<!-- Row 2: bookmark + shelf + stars -->
|
||||||
>
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{#if !data.isLoggedIn}
|
||||||
<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"/>
|
<a
|
||||||
</svg>
|
href="/login"
|
||||||
</a>
|
title={m.book_detail_signin_to_save()}
|
||||||
{:else if data.inLib}
|
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"
|
||||||
<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}
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{/if}
|
</a>
|
||||||
</button>
|
{:else if data.inLib}
|
||||||
{/if}
|
<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
|
<StarRating
|
||||||
rating={userRating}
|
rating={userRating}
|
||||||
avg={ratingAvg.avg}
|
avg={ratingAvg.avg}
|
||||||
@@ -414,20 +434,6 @@
|
|||||||
onrate={rate}
|
onrate={rate}
|
||||||
size="sm"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,7 +130,43 @@
|
|||||||
|
|
||||||
let cardEl = $state<HTMLDivElement | null>(null);
|
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) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
cancelEntryAnimation();
|
||||||
if (animating || !currentBook) return;
|
if (animating || !currentBook) return;
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
@@ -216,6 +252,8 @@
|
|||||||
|
|
||||||
if (action === 'read_now') {
|
if (action === 'read_now') {
|
||||||
goto(`/books/${book.slug}`);
|
goto(`/books/${book.slug}`);
|
||||||
|
} else {
|
||||||
|
startEntryAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,8 +531,8 @@
|
|||||||
bind:this={cardEl}
|
bind:this={cardEl}
|
||||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||||
style="
|
style="
|
||||||
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
|
transform: {activeTransform};
|
||||||
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
|
transition: {activeTransition};
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
"
|
"
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
|
|||||||
Reference in New Issue
Block a user