feat: put the library view mode in the url via shallow routing
This commit is contained in:
@@ -3,10 +3,22 @@ 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');
|
||||
@@ -58,6 +70,10 @@ export class BookCollectionState {
|
||||
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'),
|
||||
@@ -68,6 +84,24 @@ export class BookCollectionState {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
replaceState(url, page.state);
|
||||
}
|
||||
|
||||
updateSort(sortValue: string) {
|
||||
if (sortValue === this.orderBy) {
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
@@ -209,6 +243,54 @@ export class BookCollectionState {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user