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">
<div class="mr-2 flex flex-col gap-8 p-4">
<BookList books={data.continueReading} title="Continue Reading" />
<BookList books={data.recentlyAdded} title="Recently Added" />
<BookList books={data.discover} title="Discover" />
<BookList books={data.readAgain} title="Read Again" />
<!-- One shelf per built-in preset, in the order they are defined -->
{#each data.presets as preset (preset.id)}
<BookList books={data.shelves[preset.id]} title={preset.heading} />
{/each}
</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">
<!-- Show a CTA to upload books if the library is empty -->
<Empty.Root class="mb-[12vh]">
@@ -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, []]))
};
}
}