diff --git a/frontend/src/lib/components/view/book-browser.svelte b/frontend/src/lib/components/view/book-browser.svelte index 665a744..3658ab0 100644 --- a/frontend/src/lib/components/view/book-browser.svelte +++ b/frontend/src/lib/components/view/book-browser.svelte @@ -12,7 +12,9 @@ import FilterButton from './filter-button.svelte'; import SortButton from './sort-button.svelte'; import ViewToggle from './view-toggle.svelte'; + import PresetChips from './preset-chips.svelte'; import BookTable from './book-table.svelte'; + import BookRows from './book-rows.svelte'; import BatchOperationsToolbar from './batch-operations-toolbar.svelte'; import { getBookCollectionState } from '$lib/state/bookCollection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; @@ -22,7 +24,6 @@ const bookCollection = getBookCollectionState(); const bookOps = getBookOperationsState(); - let view = $state('grid'); let sentinel = $state(); let scrollContainer = $state(null); @@ -66,7 +67,14 @@
{#if !selectionState.selectionModeActive} - + bookCollection.setView(next)} + /> + +
+ +
@@ -92,8 +100,10 @@
{:else if bookCollection.books.length > 0} - {#if view === 'grid'} + {#if bookCollection.view === 'grid'} + {:else if bookCollection.view === 'list'} + {:else} {/if} diff --git a/frontend/src/lib/components/view/view-toggle.svelte b/frontend/src/lib/components/view/view-toggle.svelte index c54d372..dca2b06 100644 --- a/frontend/src/lib/components/view/view-toggle.svelte +++ b/frontend/src/lib/components/view/view-toggle.svelte @@ -1,40 +1,54 @@ - - - - - - {#snippet child({ props })} - - - - {/snippet} - - -

Grid view

-
-
-
- - - - - {#snippet child({ props })} - - - - {/snippet} - - -

List view

-
-
-
+ { + // The group emits '' when the active item is pressed again; keep the + // current view rather than leaving the browser with nothing to render. + if (next) onValueChange(next as BookView); + }} + class={className} +> + {#each BOOK_VIEWS as view (view)} + {@const Icon = ICONS[view]} + + + + {#snippet child({ props })} + + + + {/snippet} + + +

{LABELS[view]}

+
+
+
+ {/each}
diff --git a/frontend/src/lib/state/bookCollection.svelte.ts b/frontend/src/lib/state/bookCollection.svelte.ts index 8064db6..8622564 100644 --- a/frontend/src/lib/state/bookCollection.svelte.ts +++ b/frontend/src/lib/state/bookCollection.svelte.ts @@ -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(''); public orderBy = $state(''); + public view = $state('grid'); public filters = $state>({}); 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; }