feat: rebuild the book page with the jacket layout

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent f2b8f337f7
commit 4c3fd66a56
2 changed files with 479 additions and 388 deletions
+46
View File
@@ -58,6 +58,52 @@ export function formatFileSize(bytes: number, decimals = 1, binary = true) {
return `${formattedSize} ${units[exponent]}`;
}
/**
* Presentation for a book identifier.
*
* The backend stores identifiers as free-form name/value pairs, so this only
* decides how to label and link the ones we recognise. Anything unknown falls
* back to the stored name and renders as plain text.
*/
const IDENTIFIER_TYPES: Record<string, { label: string; url?: (value: string) => string }> = {
'isbn-13': { label: 'ISBN-13', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
'isbn-10': { label: 'ISBN-10', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
isbn: { label: 'ISBN', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` },
doi: { label: 'DOI', url: (v) => `https://doi.org/${encodeURI(v.trim())}` },
asin: { label: 'ASIN', url: (v) => `https://www.amazon.com/dp/${encodeURIComponent(v.trim())}` }
};
/** Order the ones worth reading first; everything else keeps its own order. */
const IDENTIFIER_ORDER = ['isbn-13', 'isbn-10', 'isbn', 'doi', 'asin'];
function isbnDigits(value: string) {
return value.replace(/[^0-9Xx]/g, '');
}
function identifierKey(name: string) {
return name.trim().toLowerCase().replace(/[\s_]+/g, '-');
}
export function describeIdentifier(name: string, value: string) {
const known = IDENTIFIER_TYPES[identifierKey(name)];
return {
label: known?.label ?? name.toUpperCase(),
href: known?.url?.(value)
};
}
export function sortIdentifiers(identifiers: Record<string, string>) {
return Object.entries(identifiers).sort(([a], [b]) => {
const ia = IDENTIFIER_ORDER.indexOf(identifierKey(a));
const ib = IDENTIFIER_ORDER.indexOf(identifierKey(b));
if (ia === -1 && ib === -1) return 0;
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
}
export function getFileType(filename: string) {
const parts = filename.split('.');
if (parts.length < 2) return 'UNKNOWN';
@@ -5,24 +5,24 @@
import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as Field from '$lib/components/ui/field/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import BookImage from '$lib/components/view/book-image.svelte';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import BookCover from '$lib/components/view/book-cover.svelte';
import type { BookFile } from '$lib/schema';
import {
Album,
BookOpenCheck,
BookOpenText,
CalendarDays,
Download,
Link,
NotebookPen,
NotebookText,
Pencil,
PlusIcon,
Tags,
Trash2
} from '@lucide/svelte';
import { formatFileSize, getFileType } from '$lib/utils.js';
import {
describeIdentifier,
formatFileSize,
getFileType,
sortIdentifiers
} from '$lib/utils.js';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.svelte.js';
@@ -31,10 +31,13 @@
import * as Table from '$lib/components/ui/table/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
import { untrack } from 'svelte';
let { data } = $props();
let book = $state(data.book);
// Seeded once, then kept in sync by the effect below — untrack says so
// explicitly rather than capturing the initial value and warning about it.
let book = $state(untrack(() => data.book));
$effect(() => {
book = data.book;
@@ -47,7 +50,20 @@
let deleteFiles = $state(true);
let fileDeleteDialogOpen = $state(false);
let fileToDelete = $state<number>();
let createShelfDialogOpen = $state(false)
let createShelfDialogOpen = $state(false);
const percent = $derived(
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
);
// The primary action states what it will actually do.
const readLabel = $derived(
book.progress?.completed
? 'Read again'
: percent > 0
? `Continue reading · ${percent}%`
: 'Read'
);
function openBookInReader(file: BookFile) {
if (getFileType(file.filename) === 'EPUB')
@@ -56,53 +72,271 @@
if (getFileType(file.filename) === 'PDF')
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer');
}
// On the band, the accent is the ground — invert the buttons against it.
const bandPrimary =
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
const bandGhost =
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
</script>
<div class="mt-4 flex flex-1 flex-col items-center">
<div class="ml-8 grid max-w-[900px] grid-cols-[minmax(250px,1fr)_2.5fr] gap-8">
<!-- Cover image and action buttons -->
<aside class="flex flex-col gap-4">
<a href="/book/{book.id}" class="w-full rounded shadow-lg drop-shadow-lg">
<BookImage
src="/api/{book.cover_image}"
class="h-full w-full overflow-hidden rounded object-cover"
/>
<div class="flex h-full flex-col overflow-y-auto">
<!-- Jacket band: the accent carries the identity, the cover overlaps its edge -->
<!--
A contained card, not a full-bleed banner. Breaking out with -mx-4 left it
inset on one side and bleeding off the other, and the negative top margin
was clipped by the layout's overflow-hidden. rounded-lg is exactly
var(--radius), so the Corners setting reaches it.
-->
<header class="rounded-lg bg-primary px-6 pt-10 pb-6 text-primary-foreground md:px-10">
<!-- items-start so the title sits on the cover's top edge; the cover's
negative bottom margin still carries the overlap below the band -->
<div class="mx-auto flex max-w-5xl items-start gap-6">
<!-- BookCover draws its own progress bar, clipped to the cover's radius -->
<BookCover {book} height={260} class="-mb-20 drop-shadow-2xl" />
{#if book?.progress?.percentage}
<Progress
value={book?.progress.percentage}
max={1}
color={book?.progress.completed ? 'bg-green-600' : 'bg-yellow-500'}
class="mt-[-8px] h-2 rounded {book?.progress.completed
? '[&>div]:bg-green-600'
: '[&>div]:bg-yellow-500'}"
/>
<div class="min-w-0 flex-1">
<h1 class="font-serif text-3xl leading-tight tracking-tight text-balance">
{book.title}
</h1>
{#if book.subtitle}
<p class="mt-1 font-serif text-lg italic text-primary-foreground/75">{book.subtitle}</p>
{/if}
</a>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<!-- If there are multiple files to choose from -->
{#if book.files.length > 1}
<!-- Read button -->
<DropdownMenu.Root>
<DropdownMenu.Trigger class="{buttonVariants({ variant: 'accent' })} drop-shadow-lg">
<BookOpenText />
Read
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each book.files as file}
<DropdownMenu.Item onclick={() => openBookInReader(file)}
>{getFileType(file.filename)}</DropdownMenu.Item
{#if book.series}
<p class="mt-1 font-mono text-xs tracking-wider text-primary-foreground/70 uppercase">
{book.series.title}{book.series_position ? ` · #${book.series_position}` : ''}
</p>
{/if}
{#if book.authors && book.authors.length > 0}
<p class="mt-2 line-clamp-1 text-primary-foreground/85">
By
{#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}
</p>
{/if}
<!-- Primary actions live on the band, not in a side rail -->
<div class="mt-5 flex flex-wrap items-center gap-2">
{#if book.files.length > 1}
<DropdownMenu.Root>
<DropdownMenu.Trigger class={bandPrimary}>
<BookOpenText class="size-4" />
{readLabel}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each book.files as file (file.id)}
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
{getFileType(file.filename)}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={bandGhost}>
<Download class="size-4" />
Download
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each book.files as file (file.id)}
<DropdownMenu.Item
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
>
{getFileType(file.filename)}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => bookOps.downloadBooks([book.id], `${book.title}.zip`)}
>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
All (ZIP)
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else if book.files.length === 1}
<button type="button" class={bandPrimary} onclick={() => openBookInReader(book.files[0])}>
<BookOpenText class="size-4" />
{readLabel}
</button>
<!-- Download Button -->
<button
type="button"
class={bandGhost}
onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
>
<Download class="size-4" />
Download
</button>
{/if}
</div>
</div>
</div>
</header>
<!--
Body. Source order is description → details → files, which is the mobile
reading order asked for. On md+ the explicit row/column placement puts the
details rail on the right and files back under the description.
-->
<!--
Same horizontal frame as the band — matching px and max-width, so the
description lines up with the cover's left edge.
-->
<div class="px-6 md:px-10">
<!--
grid-rows matters here. The rail spans both rows, and with two auto rows the
browser splits any surplus rail height evenly between them — so a one-line
description got stretched to half the rail's height, leaving a large gap
above the files card. Sizing row 1 to its content and letting row 2 take the
free space sends the slack to the bottom instead, where it is invisible.
-->
<div
class="mx-auto grid w-full max-w-5xl gap-8 pt-28 pb-10 md:grid-cols-[minmax(0,1fr)_260px] md:grid-rows-[auto_1fr] md:items-start"
>
<!-- Description -->
<section class="min-w-0 md:col-start-1 md:row-start-1">
{#if book.description}
<CollapsibleText text={book.description} maxLength={500} />
{:else}
<p class="text-sm text-muted-foreground">No description for this book yet.</p>
{/if}
</section>
<!-- Details rail -->
<aside class="flex flex-col gap-6 md:col-start-2 md:row-span-2 md:row-start-1">
<div>
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Details
</h2>
<!-- items-baseline: the 10px mono label and the 14px value sit on a
shared text baseline, so the pair reads as one line -->
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-4 gap-y-2 rounded-lg border bg-card p-4 text-sm"
>
{#if book.publisher}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
Publisher
</dt>
<dd class="m-0">{book.publisher.name}</dd>
{/if}
{#if book.published_date}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
Published
</dt>
<dd class="m-0 font-mono tabular-nums">{book.published_date}</dd>
{/if}
{#if book.pages}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
Pages
</dt>
<dd class="m-0 font-mono tabular-nums">{book.pages}</dd>
{/if}
{#if book.language}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
Language
</dt>
<dd class="m-0">{book.language}</dd>
{/if}
{#if book.edition}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
Edition
</dt>
<dd class="m-0 font-mono tabular-nums">{book.edition}</dd>
{/if}
{#each sortIdentifiers(book.identifiers) as [name, value] (name)}
{@const id = describeIdentifier(name, value)}
<dt class="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
{id.label}
</dt>
<dd class="m-0 font-mono break-all tabular-nums">
{#if id.href}
<a
href={id.href}
target="_blank"
rel="noopener noreferrer"
class="hover:text-primary hover:underline">{value}</a
>
{:else}
{value}
{/if}
</dd>
{/each}
</dl>
</div>
{#if book.tags.length > 0}
<div>
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Tags
</h2>
<div class="flex flex-wrap gap-1.5">
{#each book.tags as tag (tag.id)}
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a>
{/each}
</div>
</div>
{/if}
{#if book.lists.length > 0}
<div>
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Shelves
</h2>
<div class="flex flex-wrap gap-1.5">
{#each book.lists as shelf (shelf.id)}
<a
href="/library/{libraryState.activeLibrary!.id}/view?shelves={shelf.id}"
class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a
>
{/each}
</div>
</div>
{/if}
<div>
<h2 class="mb-3 font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Manage
</h2>
<div class="flex flex-wrap gap-2">
<!-- Mark as finished -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'outline', size: 'icon' })} {book.progress
?.completed
? 'text-success'
: ''}"
onclick={async () => {
if (book.progress?.completed) await bookOps.markBooksAsIncomplete([book.id]);
else await bookOps.markBooksAsComplete([book.id]);
}}
>
<BookOpenCheck />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>{book.progress?.completed ? 'Mark as not finished' : 'Mark as finished'}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Add to shelf -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
@@ -110,335 +344,128 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
{...props}
class="w-full {buttonVariants({
variant: 'outline',
size: 'icon'
})} drop-shadow-lg"
class={buttonVariants({ variant: 'outline', size: 'icon' })}
>
<Download />
<Album />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each book.files as file}
<!-- Download a single file -->
<DropdownMenu.Item
onclick={() =>
bookOps.downloadBookFile(book.id, file.id, file.filename)}
>{getFileType(file.filename)}</DropdownMenu.Item
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
<DropdownMenu.CheckboxItem
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
onclick={async () => {
if (book.lists.find((sh) => sh.id === shelf.id)) {
await bookshelfState.removeBooksFromShelf(shelf.id, [book.id]);
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
} else {
await bookshelfState.addBooksToShelf(shelf.id, [book.id]);
book.lists.push(shelf);
}
}}
>
{shelf.title}
</DropdownMenu.CheckboxItem>
{/each}
<!-- Download all files as zip -->
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {
bookOps.downloadBooks([book.id], `${book.title}.zip`);
}}
>
All (ZIP)
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => (createShelfDialogOpen = true)}
class="text-muted-foreground"
>
<PlusIcon class="size-4" />
New Shelf
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
<p>Add to shelf</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
<!-- There is only a single file, render simple buttons -->
<!-- Read button -->
<Button
class="w-full {buttonVariants({ variant: 'accent' })} drop-shadow-lg"
onclick={() => openBookInReader(book.files[0])}
>
<BookOpenText class="mr-2 size-4" />
Read
</Button>
<!-- Download button -->
<!-- Edit -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
onclick={() => {
bookOps.bookToEdit = book;
bookOps.editDialogOpen = true;
}}
class={buttonVariants({ variant: 'outline', size: 'icon' })}
>
<Download />
<Pencil />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
<p>Edit</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
</div>
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2">
<!-- Mark as finished button -->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class=" {buttonVariants({
variant: 'outline',
size: 'icon'
})} w-full drop-shadow-lg {book?.progress?.completed === true
? 'text-green-500'
: ''}"
onclick={async () => {
if (book?.progress?.completed === true)
await bookOps.markBooksAsIncomplete([book.id]);
else await bookOps.markBooksAsComplete([book.id]);
}}
>
<BookOpenCheck />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
{#if !book?.progress?.completed}
<p>Mark as finished</p>
{:else}
<p>Mark as not finished</p>
{/if}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Add to shelf button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Root>
<DropdownMenu.Trigger
{...props}
class="{buttonVariants({
variant: 'outline',
size: 'icon'
})} w-full drop-shadow-lg"
>
<Album />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf}
<DropdownMenu.CheckboxItem
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
onclick={async () => {
if (book.lists.find((sh) => sh.id === shelf.id)) {
await bookshelfState.removeBooksFromShelf(shelf.id, [book.id]);
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
} else {
await bookshelfState.addBooksToShelf(shelf.id, [book.id]);
book.lists.push(shelf);
}
}}
>
{shelf.title}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => createShelfDialogOpen = true}
class="text-muted-foreground ">
<PlusIcon class="size-4" />
New Shelf
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Add to shelf</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Edit button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
bookOps.bookToEdit = book;
bookOps.editDialogOpen = true;
}}
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
>
<Pencil />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Edit</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
onclick={() => {
bookOps.deleteDialogTitle = `Delete book?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles, false);
libraryState.activeLibrary!.total!--
bookshelfState.deletedBooks([book])
await history.back();
};
bookOps.deleteDialogOpen = true;
}}
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class={buttonVariants({ variant: 'outline', size: 'icon' })}
onclick={() => {
bookOps.deleteDialogTitle = `Delete book?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles, false);
libraryState.activeLibrary!.total!--;
bookshelfState.deletedBooks([book]);
await history.back();
};
bookOps.deleteDialogOpen = true;
}}
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</div>
</aside>
<main class="flex flex-col gap-1">
<!-- Title, subtitle, and authors -->
<h1 class="text-2xl font-semibold">{book.title}</h1>
{#if book?.subtitle}
<h2 class="text-lg">{book.subtitle}</h2>
{/if}
{#if book.series}
<h2 class="text-base text-muted-foreground">
{book.series.title}
{book.series_position ? `#${book.series_position}` : ''}
</h2>
{/if}
{#if book.authors && book.authors.length > 0}
<h2 class="line-clamp-1 w-full text-lg">
By
{#each book.authors as author}
<a
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
</h2>
{/if}
<div class="mt-6 flex flex-col gap-4 text-sm">
<!-- Tags -->
{#if book?.tags.length > 0}
<div class="flex items-center gap-2">
<Tags size="20" />
<span class="mr-2 text-sm">Tags: </span>
{#each book.tags as tag (tag.id)}
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a>
{/each}
</div>
{/if}
<!-- Identifiers -->
{#if book.identifiers && Object.keys(book.identifiers).length > 0}
<div class="flex items-center gap-2">
<Link size="18" />
<span class="mr-2">Identifiers: </span>
{#each Object.entries(book.identifiers) as [name, value] (name)}
<Badge>{name}</Badge>
{/each}
</div>
{/if}
<!-- Publisher -->
{#if book.publisher}
<div class="flex items-center gap-2">
<NotebookPen size="18" />
<span class="mr-2">Publisher: </span>
<span>{book.publisher.name}</span>
</div>
{/if}
<!-- Published date -->
{#if book.published_date}
<div class="flex items-center gap-2">
<CalendarDays size="18" />
<span class="mr-2">Date published: </span>
<span class="text-sm">{book.published_date}</span>
</div>
{/if}
<!-- Page count -->
{#if book.pages}
<div class="flex items-center gap-2">
<NotebookText size="18" />
<span class="mr-2">Pages: </span>
<span>{book.pages}</span>
</div>
{/if}
<!-- Description -->
{#if book.description}
<CollapsibleText text={book.description} maxLength={500} />
{/if}
<!-- Book files -->
<Accordion.Root
type="single"
class="mt-4 w-full rounded-lg bg-muted px-4 shadow-lg drop-shadow "
>
<!-- <Separator></Separator> -->
<Accordion.Item value="item-1">
<Accordion.Trigger>
<div class="ml-2 flex gap-4">
Library Files
<Badge>{book.files.length}</Badge>
</div>
</Accordion.Trigger>
<Accordion.Content>
<Table.Root>
<Table.Header>
<!-- Library files -->
<section class="min-w-0 md:col-start-1 md:row-start-2">
<Accordion.Root type="single" class="w-full rounded-lg border bg-card px-4">
<Accordion.Item value="files">
<Accordion.Trigger>
<div class="flex items-center gap-3">
Library files
<Badge>{book.files.length}</Badge>
</div>
</Accordion.Trigger>
<Accordion.Content>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[300px]">Filename</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>File type</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each book.files as file (file.id)}
<Table.Row>
<Table.Head class="w-[300px]">Filename</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>File type</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each book.files as file (file.id)}
<Table.Row>
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden">
<div class="wrap-break-word whitespace-normal">{file.filename}</div>
</Table.Cell>
<Table.Cell>{formatFileSize(file.size)}</Table.Cell>
<Table.Cell>{getFileType(file.filename)}</Table.Cell>
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
<!-- Read button -->
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'default',
size: 'icon'
})} scale-90"
>
<BookOpenText />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Read</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<!-- Download button -->
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden">
<div class="font-mono text-xs break-words whitespace-normal">
{file.filename}
</div>
</Table.Cell>
<Table.Cell class="font-mono text-xs tabular-nums">
{formatFileSize(file.size)}
</Table.Cell>
<Table.Cell><span class="font-mono text-xs">{getFileType(file.filename)}</span></Table.Cell>
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
@@ -446,48 +473,64 @@
variant: 'default',
size: 'icon'
})} scale-90"
onclick={async () => {
await bookOps.downloadBookFile(book.id, file.id, file.filename);
}}
onclick={() => openBookInReader(file)}
>
<Download />
<BookOpenText />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
<p>Read</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<!-- Delete button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'default',
size: 'icon'
})} scale-90"
onclick={async () => {
await bookOps.downloadBookFile(book.id, file.id, file.filename);
}}
>
<Download />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
fileToDelete = file.id;
fileDeleteDialogOpen = true;
}}
class="{buttonVariants({
variant: 'destructive',
size: 'icon'
})} scale-90"
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">Delete File</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
</main>
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
fileToDelete = file.id;
fileDeleteDialogOpen = true;
}}
class="{buttonVariants({
variant: 'destructive',
size: 'icon'
})} scale-90"
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">Delete file</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</section>
</div>
</div>
</div>
@@ -517,11 +560,13 @@
</AlertDialog.Content>
</AlertDialog.Root>
<ShelfCreateDialog
bind:open={createShelfDialogOpen}
<ShelfCreateDialog
bind:open={createShelfDialogOpen}
onSubmit={async (name: string) => {
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id])
book.lists.push(bookshelf)
createShelfDialogOpen = false
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [
book.id
]);
book.lists.push(bookshelf);
createShelfDialogOpen = false;
}}
/>