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(''); public orderBy = $state(''); public view = $state('grid'); public filters = $state>({}); readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc'); readonly hasActiveFilters = $derived(Object.values(this.filters).some((arr) => arr.length > 0)); public books = $state([]); 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, 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) { this.books = books.items; this.moreBooks = books.total > books.items.length; } resetState(books: PaginatedResponse, 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, filterData: DynamicFilterData ) { return setContext(BOOK_COLLECTION_KEY, new BookCollectionState(ops, books, filterData)); } export function getBookCollectionState() { return getContext>(BOOK_COLLECTION_KEY); }