feat: rebuild the table view with sortable, toggleable columns
This commit is contained in:
@@ -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
|
||||
>  
|
||||
{/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>  
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Table.Cell class="text-right">
|
||||
<BookActionsMenu {book} class="size-8" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user