Type-checks every link against the real route tree. Caught a dead one: the
book page linked tags to /tag/{id}, a route that has never existed.
314 lines
8.9 KiB
TypeScript
314 lines
8.9 KiB
TypeScript
import { getContext, setContext } from 'svelte';
|
|
import { goto, pushState, replaceState } from '$app/navigation';
|
|
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
|
|
import { page } from '$app/state';
|
|
import { BookOperationsState } from './bookOperations.svelte';
|
|
import type { BookPreset } from '$lib/presets';
|
|
|
|
/** The browse views, shared with view-toggle.svelte so the two cannot drift. */
|
|
export const BOOK_VIEWS = ['grid', 'list', 'table'] as const;
|
|
|
|
export type BookView = (typeof BOOK_VIEWS)[number];
|
|
|
|
/** Anything unrecognised falls back to grid rather than blanking the page. */
|
|
export function parseView(value: string | null | undefined): BookView {
|
|
return BOOK_VIEWS.includes(value as BookView) ? (value as BookView) : 'grid';
|
|
}
|
|
|
|
export class BookCollectionState {
|
|
public sortOrder = $state<string>('');
|
|
public orderBy = $state<string>('');
|
|
public view = $state<BookView>('grid');
|
|
public filters = $state<Record<string, string[]>>({});
|
|
|
|
readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc');
|
|
readonly hasActiveFilters = $derived(Object.values(this.filters).some((arr) => arr.length > 0));
|
|
|
|
public books = $state<Book[]>([]);
|
|
public moreBooks = $state(false);
|
|
private currentBookPage = $state(1);
|
|
public loading = $state(false);
|
|
|
|
public filterOptions: FilterOption[] = $state([]);
|
|
|
|
private ops: BookOperationsState;
|
|
|
|
readonly sortOptions = [
|
|
{
|
|
value: 'title',
|
|
name: 'Title'
|
|
},
|
|
{
|
|
value: 'created_at',
|
|
name: 'Date Added'
|
|
},
|
|
{
|
|
value: 'published_date',
|
|
name: 'Date Published'
|
|
},
|
|
{
|
|
value: 'pages',
|
|
name: 'Pages'
|
|
},
|
|
{
|
|
value: 'last_accessed',
|
|
name: 'Last Accessed'
|
|
}
|
|
];
|
|
|
|
constructor(
|
|
ops: BookOperationsState,
|
|
books: PaginatedResponse<Book>,
|
|
filterData: DynamicFilterData
|
|
) {
|
|
this.ops = ops;
|
|
this.books = books.items;
|
|
this.moreBooks = books.total > books.items.length;
|
|
this.filterOptions = this.buildFilterOptions(filterData);
|
|
|
|
// Determine sort order and direction based on page data
|
|
this.orderBy = page.url.searchParams.get('orderBy') || 'title';
|
|
this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc';
|
|
|
|
// Read during SSR too, so a shared ?view=table link renders the table on
|
|
// the server rather than flashing the grid first.
|
|
this.view = parseView(page.url.searchParams.get('view'));
|
|
|
|
// Construct initial filters based on the page data
|
|
this.filters = {
|
|
authors: page.url.searchParams.getAll('authors'),
|
|
publishers: page.url.searchParams.getAll('publishers'),
|
|
progress: page.url.searchParams.getAll('progress'),
|
|
tags: page.url.searchParams.getAll('tags'),
|
|
shelves: page.url.searchParams.getAll('shelves')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shallow routing: replaceState updates the URL and page.url without running
|
|
* any load function. Switching view changes presentation only, so it must not
|
|
* take the updateSearchParams path — that calls goto() and refetches the list.
|
|
*
|
|
* replaceState rather than pushState because a view is a preference, not a
|
|
* destination; Back should leave the page, not step through view changes.
|
|
*/
|
|
setView(next: BookView) {
|
|
if (next === this.view) return;
|
|
|
|
this.view = next;
|
|
|
|
const url = new URL(page.url);
|
|
url.searchParams.set('view', next);
|
|
// Not a route to resolve — this is the current URL with one query param
|
|
// changed, so it is already fully qualified.
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
replaceState(url, page.state);
|
|
}
|
|
|
|
updateSort(sortValue: string) {
|
|
if (sortValue === this.orderBy) {
|
|
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.orderBy = sortValue;
|
|
}
|
|
this.updateSearchParams();
|
|
}
|
|
|
|
toggleFilter(filter: string, value: string) {
|
|
if (!this.filters[filter]) {
|
|
this.filters[filter] = [];
|
|
}
|
|
|
|
const index = this.filters[filter].indexOf(value);
|
|
if (index > -1) {
|
|
this.filters[filter].splice(index, 1);
|
|
} else {
|
|
this.filters[filter].push(value);
|
|
}
|
|
this.updateSearchParams();
|
|
}
|
|
|
|
updateSearchParams() {
|
|
// Update URL
|
|
const url = new URL(window.location.href);
|
|
|
|
Object.entries(this.filters).forEach(([filter, values]) => {
|
|
url.searchParams.delete(filter);
|
|
values.forEach((val) => url.searchParams.append(filter, val.toString()));
|
|
});
|
|
|
|
url.searchParams.set('orderBy', this.orderBy);
|
|
url.searchParams.set('sortOrder', this.sortOrder);
|
|
|
|
// Same as setView: the current URL with sort params rewritten, not a route.
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto(url.toString());
|
|
|
|
this.loadNewBooks();
|
|
}
|
|
|
|
async loadNewBooks() {
|
|
this.loading = true;
|
|
|
|
const result = await this.ops.listBooks({
|
|
currentPage: 1,
|
|
pageSize: 50,
|
|
sortOrder: this.sortOrder,
|
|
orderBy: this.orderBy,
|
|
...this.filters
|
|
});
|
|
|
|
this.books = [...result.items];
|
|
this.currentBookPage = 1;
|
|
this.loading = false;
|
|
}
|
|
|
|
async loadMoreBooks() {
|
|
const result = await this.ops.listBooks({
|
|
currentPage: this.currentBookPage + 1,
|
|
pageSize: 50,
|
|
sortOrder: this.sortOrder,
|
|
orderBy: this.orderBy,
|
|
...this.filters
|
|
});
|
|
|
|
this.books = [...this.books, ...result.items];
|
|
this.moreBooks = result.total > this.books.length;
|
|
this.currentBookPage++;
|
|
}
|
|
|
|
updateBooks(books: PaginatedResponse<Book>) {
|
|
this.books = books.items;
|
|
this.moreBooks = books.total > books.items.length;
|
|
}
|
|
|
|
resetState(books: PaginatedResponse<Book>, filterData: DynamicFilterData) {
|
|
this.books = books.items;
|
|
this.moreBooks = books.total > books.items.length;
|
|
this.filterOptions = this.buildFilterOptions(filterData);
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
// Re-read sort and filter state from URL
|
|
this.orderBy = urlParams.get('orderBy') || 'title';
|
|
this.sortOrder = urlParams.get('sortOrder') || 'asc';
|
|
|
|
this.filters = {
|
|
authors: urlParams.getAll('authors'),
|
|
publishers: urlParams.getAll('publishers'),
|
|
progress: urlParams.getAll('progress'),
|
|
tags: urlParams.getAll('tags'),
|
|
shelves: urlParams.getAll('shelves')
|
|
};
|
|
}
|
|
|
|
buildFilterOptions(data: DynamicFilterData): FilterOption[] {
|
|
return [
|
|
{
|
|
value: 'shelves',
|
|
name: 'Bookshelves',
|
|
items: data.bookshelves.items
|
|
},
|
|
{
|
|
value: 'authors',
|
|
name: 'Authors',
|
|
items: data.authors.items
|
|
},
|
|
{
|
|
value: 'tags',
|
|
name: 'Tags',
|
|
items: data.tags.items
|
|
},
|
|
{
|
|
value: 'publishers',
|
|
name: 'Publishers',
|
|
items: data.publishers.items
|
|
},
|
|
{
|
|
value: 'progress',
|
|
name: 'Progress',
|
|
items: [
|
|
{ id: 'read', name: 'Read' },
|
|
{ id: 'in_progress', name: 'In Progress' },
|
|
{ id: 'unread', name: 'Not Started' }
|
|
]
|
|
}
|
|
];
|
|
}
|
|
|
|
updateFilterOptions(
|
|
filterOptions: { value: string; name: string; items: { name: string; id: number | string }[] }[]
|
|
) {
|
|
this.filterOptions = filterOptions;
|
|
}
|
|
|
|
clearFilters() {
|
|
Object.values(this.filters).forEach((val) => (val.length = 0));
|
|
this.updateSearchParams();
|
|
}
|
|
|
|
/**
|
|
* A preset is a whole view, not an extra filter — applying one replaces the
|
|
* filters and the sort. Accumulating them instead produces empty results the
|
|
* moment two overlap (Reading plus Unread returns nothing) with no visible
|
|
* reason why.
|
|
*/
|
|
applyPreset(preset: BookPreset) {
|
|
Object.values(this.filters).forEach((val) => (val.length = 0));
|
|
|
|
for (const [key, values] of Object.entries(preset.filters)) {
|
|
this.filters[key] = [...values];
|
|
}
|
|
|
|
this.orderBy = preset.orderBy ?? 'title';
|
|
this.sortOrder = preset.sortOrder ?? 'asc';
|
|
|
|
this.updateSearchParams();
|
|
}
|
|
|
|
/** Back to the unfiltered, title-sorted default. */
|
|
clearView() {
|
|
Object.values(this.filters).forEach((val) => (val.length = 0));
|
|
this.orderBy = 'title';
|
|
this.sortOrder = 'asc';
|
|
this.updateSearchParams();
|
|
}
|
|
|
|
/**
|
|
* Active only on an exact match. Adding a tag on top of a preset deselects
|
|
* the chip while keeping the filters — the chip stops claiming to describe a
|
|
* view it no longer describes.
|
|
*/
|
|
isPresetActive(preset: BookPreset) {
|
|
if (this.orderBy !== (preset.orderBy ?? 'title')) return false;
|
|
if (this.sortOrder !== (preset.sortOrder ?? 'asc')) return false;
|
|
|
|
const active = Object.entries(this.filters).filter(([, values]) => values.length > 0);
|
|
const wanted = Object.entries(preset.filters);
|
|
if (active.length !== wanted.length) return false;
|
|
|
|
return wanted.every(([key, values]) => {
|
|
const current = this.filters[key] ?? [];
|
|
return current.length === values.length && values.every((value) => current.includes(value));
|
|
});
|
|
}
|
|
|
|
isFilterSelected(filter: string, value: string) {
|
|
return this.filters[filter]?.includes(value) || false;
|
|
}
|
|
}
|
|
|
|
const BOOK_COLLECTION_KEY = Symbol('BOOK_COLLECTION');
|
|
|
|
export function setBookCollectionState(
|
|
ops: BookOperationsState,
|
|
books: PaginatedResponse<Book>,
|
|
filterData: DynamicFilterData
|
|
) {
|
|
return setContext(BOOK_COLLECTION_KEY, new BookCollectionState(ops, books, filterData));
|
|
}
|
|
|
|
export function getBookCollectionState() {
|
|
return getContext<ReturnType<typeof setBookCollectionState>>(BOOK_COLLECTION_KEY);
|
|
}
|