feat: extract built-in view presets and add filter chips
This commit is contained in:
@@ -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>
|
||||||
@@ -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, []]))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user