feat: rebuild the table view with sortable, toggleable columns

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent 75360ff603
commit f2b8f337f7
@@ -1,66 +1,294 @@
<script>
<script lang="ts">
import * as Table from '$lib/components/ui/table/index';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { Checkbox } from '$lib/components/ui/checkbox/index';
import BookImage from './book-image.svelte';
import { Badge } from '$lib/components/ui/badge/index';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import BookCover from './book-cover.svelte';
import BookActionsMenu from './book-actions-menu.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { formatFileSize, getFileType } from '$lib/utils';
import { ArrowDown, ArrowUp, Columns3 } from '@lucide/svelte';
import type { Book } from '$lib/schema';
let { books } = $props();
let { books }: { books: Book[] } = $props();
const selectedState = getBookSelectionState();
const selectionState = getBookSelectionState();
const bookCollection = getBookCollectionState();
const libraryState = getLibraryState();
/**
* Column definitions.
*
* `sort` names the backend orderBy field. Only the fields the API can
* actually order by carry one — the rest are not clickable, rather than
* offering a header that silently does nothing.
*/
type Column = {
key: string;
label: string;
sort?: string;
numeric?: boolean;
on: boolean;
fixed?: boolean;
};
let columns = $state<Column[]>([
{ key: 'title', label: 'Title', sort: 'title', on: true, fixed: true },
{ key: 'authors', label: 'Authors', on: true },
{ key: 'series', label: 'Series', on: false },
{ key: 'publisher', label: 'Publisher', on: false },
{ key: 'published', label: 'Year', sort: 'published_date', numeric: true, on: true },
{ key: 'pages', label: 'Pages', sort: 'pages', numeric: true, on: true },
{ key: 'format', label: 'Format', on: true },
{ key: 'size', label: 'Size', numeric: true, on: true },
{ key: 'added', label: 'Added', sort: 'created_at', on: false },
{ key: 'progress', label: 'Progress', sort: 'last_accessed', on: true },
{ key: 'missing', label: 'Missing', on: true }
]);
const visible = $derived(columns.filter((c) => c.on));
const allSelected = $derived(books.length > 0 && books.every((b) => selectionState.isSelected(b.id)));
/** What a record is lacking — the reason this view exists. */
function missing(book: Book) {
const gaps: string[] = [];
if (!book.cover_image) gaps.push('cover');
if (!book.description) gaps.push('description');
if (!book.identifiers || Object.keys(book.identifiers).length === 0) gaps.push('isbn');
if (!book.publisher) gaps.push('publisher');
return gaps;
}
function totalSize(book: Book) {
return book.files.reduce((n, f) => n + f.size, 0);
}
function formats(book: Book) {
return [...new Set(book.files.map((f) => getFileType(f.filename)))].join(' + ');
}
/**
* Once a selection exists, selecting is the primary interaction — the whole
* row toggles. preventDefault also stops the title and author links inside
* the row from navigating, since the click bubbles through them to here.
*/
function handleRowClick(event: MouseEvent, book: Book) {
if (!selectionState.selectionModeActive) return;
if ((event.target as HTMLElement).closest('[data-row-control]')) return;
event.preventDefault();
selectionState.toggleSelection(book);
}
/**
* BookRead does not expose created_at yet, though the column exists in the
* database and the API can already order by it — so the header sorts today
* and the cell fills in by itself once the schema catches up. See TODO.md.
*/
function addedOn(book: Book) {
const value = (book as Book & { created_at?: string }).created_at;
return value ? new Date(value).toLocaleDateString() : '—';
}
</script>
<Table.Root>
<div class="flex flex-col gap-3">
<!--
Column picker. Sorting lives on the headers, so this is all the toolbar needs.
sticky left-0 + w-fit keeps it against the viewport's left edge while the
table scrolls sideways. Without it the toolbar is as wide as the table — it
is a sibling inside the ScrollArea's fit-content wrapper — so a right
aligned button ends up off-screen once enough columns are on. This works
where the sticky columns could not, because the toolbar sits outside the
table's own overflow-x-auto container.
-->
<div class="sticky left-0 flex w-fit items-center gap-3">
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{books.length} shown
</span>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'outline', size: 'sm' })}>
<Columns3 class="size-4" />
Columns
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<!-- GroupHeading reads the group context, so it has to sit inside a Group -->
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Show columns</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each columns as column (column.key)}
<!-- closeOnSelect={false} so several columns can be toggled in one pass -->
<DropdownMenu.CheckboxItem
checked={column.on}
disabled={column.fixed}
closeOnSelect={false}
onCheckedChange={(value) => {
if (!column.fixed) column.on = value;
}}
>
{column.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<!--
The table is w-full by default, so with many columns on it compresses every
cell rather than growing. w-max lets it take the width its content needs and
scroll horizontally in the browser's ScrollArea; min-w-full keeps it filling
the container when only a few columns are on.
-->
<Table.Root class="w-max min-w-full">
<Table.Header>
<Table.Row>
<Table.Head></Table.Head>
<Table.Head class="max-w-24 min-w-16">Cover</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head class="max-w-24 min-w-16">Authors</Table.Head>
<Table.Head>Publisher</Table.Head>
<Table.Head>Published Date</Table.Head>
<Table.Head>Pages</Table.Head>
<Table.Head>Tags</Table.Head>
<Table.Head>Identifiers</Table.Head>
<Table.Head class="w-10">
<Checkbox
checked={allSelected}
aria-label="Select all loaded books"
onCheckedChange={() => {
if (allSelected) selectionState.deselectAll();
else selectionState.selectAll(books);
}}
/>
</Table.Head>
<Table.Head class="w-12"></Table.Head>
{#each visible as column (column.key)}
<Table.Head class={column.numeric ? 'text-right' : ''}>
{#if column.sort}
<!-- Sorting is server-side: the header drives the same orderBy /
sortOrder state the sort menu uses, so it orders the whole
library rather than the pages loaded so far. -->
<button
type="button"
class="inline-flex items-center gap-1 hover:text-foreground {bookCollection.orderBy ===
column.sort
? 'font-semibold text-foreground'
: ''}"
onclick={() => bookCollection.updateSort(column.sort!)}
>
{column.label}
{#if bookCollection.orderBy === column.sort}
{#if bookCollection.sortOrder === 'asc'}
<ArrowUp class="size-3" />
{:else}
<ArrowDown class="size-3" />
{/if}
{/if}
</button>
{:else}
{column.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each books as book (book.id)}
<Table.Row>
<Table.Cell
><Checkbox
checked={selectedState.isSelected(book.id)}
onCheckedChange={() => selectedState.toggleSelection(book)}
/></Table.Cell
<Table.Row
data-state={selectionState.isSelected(book.id) ? 'selected' : undefined}
onclick={(event: MouseEvent) => handleRowClick(event, book)}
class={selectionState.selectionModeActive ? 'cursor-pointer' : ''}
>
<Table.Cell>
<BookImage src="/api/{book.cover_image}" />
<Table.Cell data-row-control>
<Checkbox
checked={selectionState.isSelected(book.id)}
aria-label="Select {book.title}"
onCheckedChange={() => selectionState.toggleSelection(book)}
/>
</Table.Cell>
<Table.Cell>{book.title}</Table.Cell>
<Table.Cell>
{#each book.authors as author}
<a href="/book/{book.id}"><BookCover {book} height={36} /></a>
</Table.Cell>
{#each visible as column (column.key)}
{#if column.key === 'title'}
<Table.Cell class="max-w-[280px] truncate font-serif">
<a href="/book/{book.id}" class="hover:underline">{book.title}</a>
</Table.Cell>
{:else if column.key === 'authors'}
<Table.Cell class="max-w-[180px] truncate">
{#each book.authors as author (author.id)}
<a
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
</Table.Cell>
<Table.Cell>{book.publisher}</Table.Cell>
<Table.Cell>{book.published_date}</Table.Cell>
<Table.Cell>{book.pages}</Table.Cell>
{:else if column.key === 'series'}
<Table.Cell class="text-muted-foreground">
{book.series ? book.series.title : '—'}
</Table.Cell>
{:else if column.key === 'publisher'}
<Table.Cell class="max-w-[160px] truncate text-muted-foreground">
{book.publisher ? book.publisher.name : '—'}
</Table.Cell>
{:else if column.key === 'published'}
<Table.Cell class="text-right font-mono text-xs tabular-nums">
{book.published_date ? new Date(book.published_date).getFullYear() : '—'}
</Table.Cell>
{:else if column.key === 'pages'}
<Table.Cell class="text-right font-mono text-xs tabular-nums">
{book.pages ?? '—'}
</Table.Cell>
{:else if column.key === 'format'}
<Table.Cell><span class="font-mono text-xs">{formats(book)}</span></Table.Cell>
{:else if column.key === 'size'}
<Table.Cell class="text-right font-mono text-xs tabular-nums">
{formatFileSize(totalSize(book))}
</Table.Cell>
{:else if column.key === 'added'}
<Table.Cell class="font-mono text-xs tabular-nums text-muted-foreground">
{addedOn(book)}
</Table.Cell>
{:else if column.key === 'progress'}
<Table.Cell>
{#each book.tags as tag}
{tag.name}
{#if book.progress?.completed}
<span class="font-mono text-xs font-semibold text-success">Finished</span>
{:else if book.progress?.percentage}
<span class="flex items-center gap-2">
<span class="h-1 w-16 overflow-hidden rounded-full bg-muted-foreground/25">
<span
class="block h-full bg-flag"
style="width: {Math.round(book.progress.percentage * 100)}%;"
></span>
</span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">
{Math.round(book.progress.percentage * 100)}%
</span>
</span>
{:else}
<span class="font-mono text-xs text-muted-foreground">Unread</span>
{/if}
</Table.Cell>
{:else if column.key === 'missing'}
<Table.Cell>
{#each missing(book) as gap (gap)}
<Badge variant="outline" class="mr-1 border-flag font-mono text-[10px] text-flag">
{gap}
</Badge>
{:else}
<span class="font-mono text-xs text-success">complete</span>
{/each}
</Table.Cell>
<Table.Cell>
{#each Object.entries(book.identifiers) as [name, value] (value)}
<span class="cs-list">{name}</span> &thinsp;
{/if}
{/each}
<Table.Cell class="text-right">
<BookActionsMenu {book} class="size-8" />
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>