feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated sorting (only changes on metadata writes) with chapters_idx sorted by -created, so the section now reflects actual chapter activity. Add per-section show/hide toggles on the home page, persisted in localStorage via Svelte 5 $state. Each section header gets a small hide button; hidden sections appear as restore chips above the footer. Toggleable: Recently Updated, Browse by Genre, From Following. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -299,7 +299,8 @@ export async function invalidateBooksCache(): Promise<void> {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
cache.invalidate(BOOKS_CACHE_KEY),
|
cache.invalidate(BOOKS_CACHE_KEY),
|
||||||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||||
cache.invalidatePattern('books:recent:*')
|
cache.invalidatePattern('books:recent:*'),
|
||||||
|
cache.invalidatePattern('books:recently-updated:*')
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,10 +313,46 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
|||||||
const cached = await cache.get<Book[]>(key);
|
const cached = await cache.get<Book[]>(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
||||||
await cache.set(key, books, 5 * 60); // 5 minutes
|
await cache.set(key, books, 5 * 60);
|
||||||
return books;
|
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 {
|
||||||
totalBooks: number;
|
totalBooks: number;
|
||||||
totalChapters: number;
|
totalChapters: number;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user