Compare commits

...

3 Commits

Author SHA1 Message Date
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:15:58 +05:00
6 changed files with 101 additions and 24 deletions

View File

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

View File

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

View File

@@ -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,11 @@ 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:*')
]);
}
export async function getBook(slug: string): Promise<Book | null> {
@@ -290,7 +308,12 @@ 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); // 5 minutes
return books;
}
export interface HomeStats {
@@ -299,11 +322,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 ────────────────────────────────────────────────────────────
@@ -1849,6 +1880,7 @@ export async function setBookRating(
} else {
await pbPost('/api/collections/book_ratings/records', payload);
}
await cache.invalidate(RATINGS_CACHE_KEY);
}
// ─── Shelves ───────────────────────────────────────────────────────────────────
@@ -1918,7 +1950,7 @@ export async function getBooksForDiscovery(
// 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 listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
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 };

View File

@@ -88,6 +88,7 @@
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
let settingsDirty = false; // true only after the first apply completes
$effect(() => {
if (data.settings) {
if (!settingsApplied) {
@@ -100,6 +101,9 @@
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);
}
});
@@ -114,8 +118,9 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
if (!settingsDirty) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {

View File

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

View File

@@ -130,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;
@@ -216,6 +252,8 @@
if (action === 'read_now') {
goto(`/books/${book.slug}`);
} else {
startEntryAnimation();
}
}
@@ -493,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}