feat: extract built-in view presets and add filter chips

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent 4c3fd66a56
commit 0139f6f5eb
4 changed files with 131 additions and 32 deletions
@@ -0,0 +1,27 @@
<script lang="ts">
import { BOOK_PRESETS } from '$lib/presets';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
const bookCollection = getBookCollectionState();
</script>
<!--
Built-in views, not shelves: a fixed set every library has, so nothing here is
named, owned or persisted. The same definitions drive the home page's shelves,
so "Recently added" means the same thing in both places.
-->
<div class="flex items-center gap-1.5 overflow-x-auto" aria-label="Preset views">
{#each BOOK_PRESETS as preset (preset.id)}
{@const active = bookCollection.isPresetActive(preset)}
<button
type="button"
aria-pressed={active}
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active
? 'border-primary bg-primary text-primary-foreground font-medium'
: 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}"
>
{preset.label}
</button>
{/each}
</div>
+79
View File
@@ -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<string, string[]>;
/** 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();
}
@@ -14,13 +14,14 @@
<ScrollArea class="h-full w-full"> <ScrollArea class="h-full w-full">
<div class="mr-2 flex flex-col gap-8 p-4"> <div class="mr-2 flex flex-col gap-8 p-4">
<BookList books={data.continueReading} title="Continue Reading" /> <!-- One shelf per built-in preset, in the order they are defined -->
<BookList books={data.recentlyAdded} title="Recently Added" /> {#each data.presets as preset (preset.id)}
<BookList books={data.discover} title="Discover" /> <BookList books={data.shelves[preset.id]} title={preset.heading} />
<BookList books={data.readAgain} title="Read Again" /> {/each}
</div> </div>
{#if data.recentlyAdded.length === 0 } <!-- Empty when no preset returned anything, i.e. the library has no books -->
{#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
<div class="flex flex-col items-center justify-center w-full h-full"> <div class="flex flex-col items-center justify-center w-full h-full">
<!-- Show a CTA to upload books if the library is empty --> <!-- Show a CTA to upload books if the library is empty -->
<Empty.Root class="mb-[12vh]"> <Empty.Root class="mb-[12vh]">
@@ -1,40 +1,32 @@
import { BOOK_PRESETS, presetQuery } from '$lib/presets';
export async function load({ params, fetch }) { export async function load({ params, fetch }) {
try { try {
const continueReadingResponse = await fetch( // The shelves are the built-in presets, fetched in parallel rather than
`/api/books?libraries=${params.libraryId}&progress=in_progress&orderBy=last_accessed&sortOrder=desc` // 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( const shelves = Object.fromEntries(responses);
`/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'];
return { return {
continueReading, presets: BOOK_PRESETS,
discover, shelves
readAgain,
recentlyAdded
}; };
} catch (error) { } catch (error) {
console.error('Error fetching books from library: ', 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 { return {
status: error.status || 500, presets: BOOK_PRESETS,
error: error.message shelves: Object.fromEntries(BOOK_PRESETS.map((preset) => [preset.id, []]))
}; };
} }
} }