From 0139f6f5eb66f7a9f0fd21b42d72bfc8c24d676c Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:39 -0400 Subject: [PATCH] feat: extract built-in view presets and add filter chips --- .../lib/components/view/preset-chips.svelte | 27 +++++++ frontend/src/lib/presets.ts | 79 +++++++++++++++++++ .../library/[libraryId]/+page.svelte | 11 +-- .../(library)/library/[libraryId]/+page.ts | 46 +++++------ 4 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/components/view/preset-chips.svelte create mode 100644 frontend/src/lib/presets.ts diff --git a/frontend/src/lib/components/view/preset-chips.svelte b/frontend/src/lib/components/view/preset-chips.svelte new file mode 100644 index 0000000..a0319d9 --- /dev/null +++ b/frontend/src/lib/components/view/preset-chips.svelte @@ -0,0 +1,27 @@ + + + +
+ {#each BOOK_PRESETS as preset (preset.id)} + {@const active = bookCollection.isPresetActive(preset)} + + {/each} +
diff --git a/frontend/src/lib/presets.ts b/frontend/src/lib/presets.ts new file mode 100644 index 0000000..71d05a0 --- /dev/null +++ b/frontend/src/lib/presets.ts @@ -0,0 +1,79 @@ +/** + * Built-in views over a library. + * + * These are navigation, not content — a fixed set of lenses every library has, + * as distinct from shelves, which are named collections a user owns. Nothing + * here is persisted or user-editable. + * + * Defined once and consumed twice: the home route renders them as shelves, and + * the library view renders them as chips. Previously each home shelf was a + * hand-written query string in the loader, which is how `create_at` (missing a + * `d`) went unnoticed — that sort silently did nothing. + */ + +export interface BookPreset { + id: string; + label: string; + /** Shown as the shelf heading on the home page. */ + heading: string; + /** Filter values, keyed the way the books endpoint expects them. */ + filters: Record; + /** Backend sort field; omitted means "leave the current ordering alone". */ + orderBy?: string; + /** asc, desc, or random — the API's CustomOrderBy accepts all three. */ + sortOrder?: string; +} + +export const BOOK_PRESETS: BookPreset[] = [ + { + id: 'continue-reading', + label: 'Reading', + heading: 'Continue Reading', + filters: { progress: ['in_progress'] }, + orderBy: 'last_accessed', + sortOrder: 'desc' + }, + { + id: 'recently-added', + label: 'Recently added', + heading: 'Recently Added', + filters: { progress: ['unread'] }, + orderBy: 'created_at', + sortOrder: 'desc' + }, + { + id: 'discover', + label: 'Discover', + heading: 'Discover', + filters: { progress: ['unread'] }, + sortOrder: 'random' + }, + { + id: 'read-again', + label: 'Read again', + heading: 'Read Again', + filters: { progress: ['read'] }, + sortOrder: 'random' + } +]; + +export function getPreset(id: string) { + return BOOK_PRESETS.find((preset) => preset.id === id); +} + +/** + * Query string for the books endpoint, used by the home route's loader. + */ +export function presetQuery(preset: BookPreset, libraryId: string | number) { + const params = new URLSearchParams(); + params.set('libraries', String(libraryId)); + + for (const [key, values] of Object.entries(preset.filters)) { + for (const value of values) params.append(key, value); + } + + if (preset.orderBy) params.set('orderBy', preset.orderBy); + if (preset.sortOrder) params.set('sortOrder', preset.sortOrder); + + return params.toString(); +} diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte index 7fbab30..a5ddd4b 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte @@ -14,13 +14,14 @@
- - - - + + {#each data.presets as preset (preset.id)} + + {/each}
- {#if data.recentlyAdded.length === 0 } + + {#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts index c7c8cac..c0c9c07 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts @@ -1,40 +1,32 @@ +import { BOOK_PRESETS, presetQuery } from '$lib/presets'; + export async function load({ params, fetch }) { try { - const continueReadingResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=in_progress&orderBy=last_accessed&sortOrder=desc` + // The shelves are the built-in presets, fetched in parallel rather than + // one after another as before. + const responses = await Promise.all( + BOOK_PRESETS.map(async (preset) => { + const response = await fetch(`/api/books?${presetQuery(preset, params.libraryId)}`); + const result = await response.json(); + return [preset.id, result['items'] ?? []] as const; + }) ); - let results = await continueReadingResponse.json(); - let continueReading = results['items']; - const discoverResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=unread&sortOrder=random` - ); - results = await discoverResponse.json(); - let discover = results['items']; - - const readAgainResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=read&sortOrder=random` - ); - results = await readAgainResponse.json(); - let readAgain = results['items']; - - const recentlyAddedResponse = await fetch( - `/api/books?libraries=${params.libraryId}&orderBy=create_at&sortOrder=desc&progress=unread` - ); - results = await recentlyAddedResponse.json(); - let recentlyAdded = results['items']; + const shelves = Object.fromEntries(responses); return { - continueReading, - discover, - readAgain, - recentlyAdded + presets: BOOK_PRESETS, + shelves }; } catch (error) { console.error('Error fetching books from library: ', error); + + // Same shape as the success path, so the page renders its empty state + // instead of the caller having to guard every field. The previous + // {status, error} return was consumed by nothing. return { - status: error.status || 500, - error: error.message + presets: BOOK_PRESETS, + shelves: Object.fromEntries(BOOK_PRESETS.map((preset) => [preset.id, []])) }; } }