Dead imports and locals removed, each blocks keyed, `any` narrowed to unknown. Two context setters kept their calls and lost only the unused binding; the settings redirect no longer awaits a parent whose data it discards. A leading underscore now marks a binding that only holds a position.
304 lines
11 KiB
Svelte
304 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import * as Table from '$lib/components/ui/table/index';
|
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
|
import { Checkbox } from '$lib/components/ui/checkbox/index';
|
|
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 }: { books: Book[] } = $props();
|
|
|
|
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>
|
|
|
|
<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 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
|
|
data-state={selectionState.isSelected(book.id) ? 'selected' : undefined}
|
|
onclick={(event: MouseEvent) => handleRowClick(event, book)}
|
|
class={selectionState.selectionModeActive ? 'cursor-pointer' : ''}
|
|
>
|
|
<Table.Cell data-row-control>
|
|
<Checkbox
|
|
checked={selectionState.isSelected(book.id)}
|
|
aria-label="Select {book.title}"
|
|
onCheckedChange={() => selectionState.toggleSelection(book)}
|
|
/>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(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={resolve('/(root)/(library)/book/[bookId]', { bookId: String(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="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
|
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
|
})}?authors={author.id}"
|
|
class="cs-list hover:underline">{author.name}</a
|
|
>  
|
|
{/each}
|
|
</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 text-muted-foreground tabular-nums">
|
|
{addedOn(book)}
|
|
</Table.Cell>
|
|
{:else if column.key === 'progress'}
|
|
<Table.Cell>
|
|
{#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 text-muted-foreground tabular-nums">
|
|
{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>
|
|
{/if}
|
|
{/each}
|
|
|
|
<Table.Cell class="text-right">
|
|
<BookActionsMenu {book} class="size-8" />
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
{/each}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</div>
|