Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7b686fba | ||
|
|
24d73cb730 | ||
|
|
19aeb90403 |
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user