feat: put the library view mode in the url via shallow routing

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent 0139f6f5eb
commit c09b8365fa
3 changed files with 142 additions and 36 deletions
@@ -12,7 +12,9 @@
import FilterButton from './filter-button.svelte'; import FilterButton from './filter-button.svelte';
import SortButton from './sort-button.svelte'; import SortButton from './sort-button.svelte';
import ViewToggle from './view-toggle.svelte'; import ViewToggle from './view-toggle.svelte';
import PresetChips from './preset-chips.svelte';
import BookTable from './book-table.svelte'; import BookTable from './book-table.svelte';
import BookRows from './book-rows.svelte';
import BatchOperationsToolbar from './batch-operations-toolbar.svelte'; import BatchOperationsToolbar from './batch-operations-toolbar.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte'; import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
@@ -22,7 +24,6 @@
const bookCollection = getBookCollectionState(); const bookCollection = getBookCollectionState();
const bookOps = getBookOperationsState(); const bookOps = getBookOperationsState();
let view = $state('grid');
let sentinel = $state<HTMLElement>(); let sentinel = $state<HTMLElement>();
let scrollContainer = $state<HTMLElement | null>(null); let scrollContainer = $state<HTMLElement | null>(null);
@@ -66,7 +67,14 @@
<div class="top-0 z-1 mb-4 flex h-12 w-full rounded-lg border bg-sidebar px-5"> <div class="top-0 z-1 mb-4 flex h-12 w-full rounded-lg border bg-sidebar px-5">
<div class="flex w-full items-center py-2"> <div class="flex w-full items-center py-2">
{#if !selectionState.selectionModeActive} {#if !selectionState.selectionModeActive}
<ViewToggle bind:view /> <ViewToggle
value={bookCollection.view}
onValueChange={(next) => bookCollection.setView(next)}
/>
<div class="mx-4 min-w-0 flex-1">
<PresetChips />
</div>
<div class="ml-auto"> <div class="ml-auto">
<SortButton /> <SortButton />
@@ -92,8 +100,10 @@
</div> </div>
{:else if bookCollection.books.length > 0} {:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5"> <ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5">
{#if view === 'grid'} {#if bookCollection.view === 'grid'}
<BookGrid books={bookCollection.books} /> <BookGrid books={bookCollection.books} />
{:else if bookCollection.view === 'list'}
<BookRows books={bookCollection.books} />
{:else} {:else}
<BookTable books={bookCollection.books} /> <BookTable books={bookCollection.books} />
{/if} {/if}
@@ -1,40 +1,54 @@
<script> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index'; import * as ToggleGroup from '$lib/components/ui/toggle-group/index';
import { BOOK_VIEWS, type BookView } from '$lib/state/bookCollection.svelte';
import { List, LayoutGrid } from '@lucide/svelte'; import { List, LayoutGrid, Table } from '@lucide/svelte';
let { view = $bindable(), class: className = '' } = $props(); /**
* Controlled rather than bound: the parent writes the URL when the view
* changes, and a callback keeps that write next to the intent instead of
* needing an effect that also fires on the initial assignment.
*/
let {
value,
onValueChange,
class: className = ''
}: {
value: BookView;
onValueChange: (view: BookView) => void;
class?: string;
} = $props();
const ICONS = { grid: LayoutGrid, list: List, table: Table };
const LABELS = { grid: 'Grid view', list: 'List view', table: 'Table view' };
</script> </script>
<ToggleGroup.Root type="single" bind:value={view} class={className}> <ToggleGroup.Root
<Tooltip.Provider> type="single"
<Tooltip.Root> {value}
<Tooltip.Trigger> onValueChange={(next) => {
{#snippet child({ props })} // The group emits '' when the active item is pressed again; keep the
<ToggleGroup.Item value="grid" aria-label="Toggle grid view" {...props}> // current view rather than leaving the browser with nothing to render.
<LayoutGrid class="size-4" /> if (next) onValueChange(next as BookView);
</ToggleGroup.Item> }}
{/snippet} class={className}
</Tooltip.Trigger> >
<Tooltip.Content> {#each BOOK_VIEWS as view (view)}
<p>Grid view</p> {@const Icon = ICONS[view]}
</Tooltip.Content> <Tooltip.Provider>
</Tooltip.Root> <Tooltip.Root ignoreNonKeyboardFocus>
</Tooltip.Provider> <Tooltip.Trigger>
{#snippet child({ props })}
<Tooltip.Provider> <ToggleGroup.Item value={view} aria-label={LABELS[view]} {...props}>
<Tooltip.Root> <Icon class="size-4" />
<Tooltip.Trigger> </ToggleGroup.Item>
{#snippet child({ props })} {/snippet}
<ToggleGroup.Item value="list" aria-label="Toggle list view" {...props}> </Tooltip.Trigger>
<List class="size-4" /> <Tooltip.Content side="bottom">
</ToggleGroup.Item> <p>{LABELS[view]}</p>
{/snippet} </Tooltip.Content>
</Tooltip.Trigger> </Tooltip.Root>
<Tooltip.Content> </Tooltip.Provider>
<p>List view</p> {/each}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</ToggleGroup.Root> </ToggleGroup.Root>
@@ -3,10 +3,22 @@ import { goto, pushState, replaceState } from '$app/navigation';
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema'; import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
import { page } from '$app/state'; import { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte'; 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 { export class BookCollectionState {
public sortOrder = $state<string>(''); public sortOrder = $state<string>('');
public orderBy = $state<string>(''); public orderBy = $state<string>('');
public view = $state<BookView>('grid');
public filters = $state<Record<string, string[]>>({}); public filters = $state<Record<string, string[]>>({});
readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc'); 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.orderBy = page.url.searchParams.get('orderBy') || 'title';
this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc'; 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 // Construct initial filters based on the page data
this.filters = { this.filters = {
authors: page.url.searchParams.getAll('authors'), 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) { updateSort(sortValue: string) {
if (sortValue === this.orderBy) { if (sortValue === this.orderBy) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
@@ -209,6 +243,54 @@ export class BookCollectionState {
this.updateSearchParams(); 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) { isFilterSelected(filter: string, value: string) {
return this.filters[filter]?.includes(value) || false; return this.filters[filter]?.includes(value) || false;
} }