feat: put the library view mode in the url via shallow routing
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user