feat: add book cover component and detail-card list view
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||||
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
|
import { getFileType } from '$lib/utils';
|
||||||
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
|
BookOpenText,
|
||||||
|
Download,
|
||||||
|
EllipsisVertical,
|
||||||
|
Pencil,
|
||||||
|
Trash2
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import type { Book, BookFile } from '$lib/schema';
|
||||||
|
|
||||||
|
let { book, class: className = '' }: { book: Book; class?: string } = $props();
|
||||||
|
|
||||||
|
const bookOps = getBookOperationsState();
|
||||||
|
const libraryState = getLibraryState();
|
||||||
|
const bookshelfState = getBookshelfState();
|
||||||
|
|
||||||
|
function openInReader(file: BookFile) {
|
||||||
|
const type = getFileType(file.filename);
|
||||||
|
if (type === 'EPUB' || type === 'PDF')
|
||||||
|
window.open(`/book/${book.id}/read/${type.toLowerCase()}/${file.id}`, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The per-book overflow menu, shared so the grid, list and table cannot drift
|
||||||
|
apart. data-row-control keeps a click here from toggling row selection.
|
||||||
|
-->
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
data-row-control
|
||||||
|
aria-label="More actions for {book.title}"
|
||||||
|
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} {className}"
|
||||||
|
>
|
||||||
|
<EllipsisVertical class="size-4" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Content align="end" data-row-control>
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
{#if book.files.length > 0}
|
||||||
|
<DropdownMenu.Item onclick={() => openInReader(book.files[0])}>
|
||||||
|
<BookOpenText class="size-4" />
|
||||||
|
Read
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||||
|
>
|
||||||
|
<Download class="size-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => {
|
||||||
|
bookOps.bookToEdit = book;
|
||||||
|
bookOps.editDialogOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={async () => {
|
||||||
|
if (!book.progress?.completed) await bookOps.markBooksAsComplete([book.id]);
|
||||||
|
else await bookOps.markBooksAsIncomplete([book.id]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookOpenCheck class="size-4" />
|
||||||
|
{book.progress?.completed ? 'Mark as unfinished' : 'Mark as finished'}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
|
||||||
|
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||||
|
await bookOps.deleteBooks([book.id], deleteFiles);
|
||||||
|
libraryState.activeLibrary!.total!--;
|
||||||
|
bookshelfState.deletedBooks([book]);
|
||||||
|
};
|
||||||
|
bookOps.deleteDialogOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
|
let {
|
||||||
|
book,
|
||||||
|
height = 220,
|
||||||
|
class: className = ''
|
||||||
|
}: { book: Book; height?: number; class?: string } = $props();
|
||||||
|
|
||||||
|
let failed = $state(false);
|
||||||
|
|
||||||
|
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
|
||||||
|
|
||||||
|
// A stable hue per title, so a book with no artwork still gets its own
|
||||||
|
// colour rather than every placeholder looking identical.
|
||||||
|
const hue = $derived([...book.title].reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) % 360, 7));
|
||||||
|
|
||||||
|
const authors = $derived(book.authors.map((a) => a.name).join(', '));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Covers arrive at whatever size the EPUB or PDF carried — the backend converts
|
||||||
|
to WebP without resizing. This shows the whole cover at its own aspect ratio:
|
||||||
|
the height is fixed so callers can rely on it for layout, and the width falls
|
||||||
|
out of the image. Nothing is cropped and nothing is stretched.
|
||||||
|
|
||||||
|
The wrapper reserves a minimum width so the text beside it only settles by a
|
||||||
|
few pixels once the image loads. Removing that last reflow needs the intrinsic
|
||||||
|
dimensions stored at ingest.
|
||||||
|
|
||||||
|
That reservation is proportional to the height, not a fixed value: a 2:3 cover
|
||||||
|
is 0.67x its height wide, so 0.6x reserves almost all of it without ever
|
||||||
|
overshooting. A flat minimum would make the wrapper — and any link wrapping
|
||||||
|
it — far wider than a small cover.
|
||||||
|
-->
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-end {className}"
|
||||||
|
style="height: {height}px; min-width: {Math.round(height * 0.6)}px;"
|
||||||
|
>
|
||||||
|
{#if src && !failed}
|
||||||
|
<!--
|
||||||
|
The rounded box lives on this wrapper, not the image, so anything
|
||||||
|
overlaid on the cover — the progress bar — is clipped to the same
|
||||||
|
silhouette instead of squaring off its corners. The shadow moves here
|
||||||
|
too, since overflow-hidden would otherwise clip the image's own.
|
||||||
|
-->
|
||||||
|
<span class="relative h-full overflow-hidden rounded-sm shadow-lg">
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="Cover of {book.title}"
|
||||||
|
onerror={() => (failed = true)}
|
||||||
|
class="h-full w-auto object-contain"
|
||||||
|
style="max-width: {Math.round(height * 0.95)}px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if book.progress?.percentage}
|
||||||
|
<span class="absolute inset-x-0 bottom-0 h-1 bg-black/30">
|
||||||
|
<span
|
||||||
|
class="block h-full {book.progress.completed ? 'bg-success' : 'bg-flag'}"
|
||||||
|
style="width: {Math.min(100, Math.round(book.progress.percentage * 100))}%;"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<!-- No cover on record, or the file is missing. Draw one. -->
|
||||||
|
<span
|
||||||
|
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg"
|
||||||
|
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue}
|
||||||
|
30% 34%), hsl({hue} 38% 18%));"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="line-clamp-4 font-serif text-xs leading-tight"
|
||||||
|
style="color: hsl({hue} 38% 95%);">{book.title}</span
|
||||||
|
>
|
||||||
|
{#if authors}
|
||||||
|
<span
|
||||||
|
class="line-clamp-2 font-mono text-[8px] tracking-wider uppercase"
|
||||||
|
style="color: hsl({hue} 24% 78%);">{authors}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BookCover from './book-cover.svelte';
|
||||||
|
import BookActionsMenu from './book-actions-menu.svelte';
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
|
import { Badge, badgeVariants } from '$lib/components/ui/badge/index';
|
||||||
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||||
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
|
import { getFileType } from '$lib/utils';
|
||||||
|
import { BookOpenText, Check, Download } from '@lucide/svelte';
|
||||||
|
import type { Book, BookFile } from '$lib/schema';
|
||||||
|
|
||||||
|
let { books }: { books: Book[] } = $props();
|
||||||
|
|
||||||
|
const selectionState = getBookSelectionState();
|
||||||
|
const bookOps = getBookOperationsState();
|
||||||
|
const libraryState = getLibraryState();
|
||||||
|
|
||||||
|
/** At most three, so cards with a heavy tag list stay the same height as the rest. */
|
||||||
|
const TAG_LIMIT = 3;
|
||||||
|
|
||||||
|
function authors(book: Book) {
|
||||||
|
return book.authors.map((a) => a.name).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function year(book: Book) {
|
||||||
|
return book.published_date ? new Date(book.published_date).getFullYear() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formats(book: Book) {
|
||||||
|
return [...new Set(book.files.map((f) => getFileType(f.filename)))].join(' + ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookProgressRead does not expose a timestamp yet, though BookProgress
|
||||||
|
* extends BigIntAuditBase so updated_at exists in the database. Reading it
|
||||||
|
* defensively means the date appears on its own once the schema catches up.
|
||||||
|
* See TODO.md.
|
||||||
|
*/
|
||||||
|
function finishedOn(book: Book) {
|
||||||
|
const value = (book.progress as { updated_at?: string } | null | undefined)?.updated_at;
|
||||||
|
return value
|
||||||
|
? new Date(value).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line where the progress bar used to be — the cover already draws the
|
||||||
|
* bar, so this says something the bar cannot. Unread books report their
|
||||||
|
* length rather than announcing that they are unread.
|
||||||
|
*/
|
||||||
|
function readState(book: Book): { text: string; tone: 'done' | 'reading' | 'idle' } {
|
||||||
|
const progress = book.progress;
|
||||||
|
const pages = book.pages ?? null;
|
||||||
|
|
||||||
|
if (progress?.completed) {
|
||||||
|
const on = finishedOn(book);
|
||||||
|
return { text: on ? `Finished ${on}` : 'Finished', tone: 'done' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress?.percentage) {
|
||||||
|
// pdf_page is a real page number when the reader recorded one;
|
||||||
|
// otherwise infer it from the percentage.
|
||||||
|
const current = progress.pdf_page ?? (pages ? Math.round(progress.percentage * pages) : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
pages && current
|
||||||
|
? `Reading · ${current} of ${pages}`
|
||||||
|
: `Reading · ${Math.round(progress.percentage * 100)}%`,
|
||||||
|
tone: 'reading'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: pages ? `${pages} pages` : '', tone: 'idle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInReader(book: Book, file: BookFile) {
|
||||||
|
const type = getFileType(file.filename);
|
||||||
|
if (type === 'EPUB' || type === 'PDF')
|
||||||
|
window.open(`/book/${book.id}/read/${type.toLowerCase()}/${file.id}`, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a selection exists, selecting is the primary interaction — so the
|
||||||
|
* whole card toggles, not just the checkbox. preventDefault also stops the
|
||||||
|
* links inside from navigating, since the click bubbles through them here.
|
||||||
|
*/
|
||||||
|
function handleCardClick(event: MouseEvent, book: Book) {
|
||||||
|
if (!selectionState.selectionModeActive) return;
|
||||||
|
if ((event.target as HTMLElement).closest('[data-row-control]')) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
selectionState.toggleSelection(book);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Detail cards: a browsable cover beside real metadata, sitting between the
|
||||||
|
grid (covers, no data) and the table (data, no covers). The column count
|
||||||
|
comes from the container rather than a breakpoint, so it reflows from one to
|
||||||
|
three as the sidebar opens and closes.
|
||||||
|
-->
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fill,minmax(330px,1fr))] gap-3.5 p-1">
|
||||||
|
{#each books as book (book.id)}
|
||||||
|
{@const selected = selectionState.isSelected(book.id)}
|
||||||
|
{@const state = readState(book)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
onclick={(event) => handleCardClick(event, book)}
|
||||||
|
class="group flex gap-3.5 rounded-lg border bg-card p-3 transition-colors {selected
|
||||||
|
? 'border-primary bg-accent'
|
||||||
|
: 'border-border/60 hover:border-border'} {selectionState.selectionModeActive
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<a href="/book/{book.id}" class="shrink-0">
|
||||||
|
<BookCover {book} height={110} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a
|
||||||
|
href="/book/{book.id}"
|
||||||
|
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
|
||||||
|
>
|
||||||
|
{book.title}
|
||||||
|
</a>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-muted-foreground">{authors(book)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden until hover, pinned once selected — as in the grid -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-row-control
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected}
|
||||||
|
aria-label="Select {book.title}"
|
||||||
|
onclick={() => selectionState.toggleSelection(book)}
|
||||||
|
class="grid size-4 shrink-0 place-items-center rounded-sm border transition-opacity {selected
|
||||||
|
? 'border-primary bg-primary text-primary-foreground opacity-100'
|
||||||
|
: 'border-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100'}"
|
||||||
|
>
|
||||||
|
{#if selected}
|
||||||
|
<Check class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-1">
|
||||||
|
{#if year(book)}
|
||||||
|
<Badge variant="secondary" class="font-mono text-[10px] tabular-nums">
|
||||||
|
{year(book)}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if book.files.length > 0}
|
||||||
|
<Badge variant="outline" class="font-mono text-[10px]">{formats(book)}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
|
||||||
|
<a
|
||||||
|
data-row-control
|
||||||
|
href="/library/{libraryState.activeLibrary?.id}/view?tags={tag.id}"
|
||||||
|
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if book.tags.length > TAG_LIMIT}
|
||||||
|
<span class="font-mono text-[10px] text-muted-foreground">
|
||||||
|
+{book.tags.length - TAG_LIMIT}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto flex items-center gap-2 pt-2.5">
|
||||||
|
<!-- Actions on the left, reading state anchored bottom-right -->
|
||||||
|
<div
|
||||||
|
data-row-control
|
||||||
|
class="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{#if book.files.length > 0}
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
|
<Tooltip.Trigger
|
||||||
|
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-7"
|
||||||
|
onclick={() => openInReader(book, book.files[0])}
|
||||||
|
>
|
||||||
|
<BookOpenText class="size-4" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side="bottom"><p>Read</p></Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
|
<Tooltip.Trigger
|
||||||
|
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-7"
|
||||||
|
onclick={() =>
|
||||||
|
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||||
|
>
|
||||||
|
<Download class="size-4" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side="bottom"><p>Download</p></Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<BookActionsMenu {book} class="size-7" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.text}
|
||||||
|
<span
|
||||||
|
class="ml-auto truncate font-mono text-xs tabular-nums {state.tone === 'done'
|
||||||
|
? 'font-semibold text-success'
|
||||||
|
: state.tone === 'reading'
|
||||||
|
? 'font-semibold text-flag'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{state.text}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user