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]}`; 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) { export function getFileType(filename: string) {
const parts = filename.split('.'); const parts = filename.split('.');
if (parts.length < 2) return 'UNKNOWN'; if (parts.length < 2) return 'UNKNOWN';
@@ -5,24 +5,24 @@
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; import { buttonVariants } from '$lib/components/ui/button/index.js';
import BookImage from '$lib/components/view/book-image.svelte'; import BookCover from '$lib/components/view/book-cover.svelte';
import type { BookFile } from '$lib/schema'; import type { BookFile } from '$lib/schema';
import { import {
Album, Album,
BookOpenCheck, BookOpenCheck,
BookOpenText, BookOpenText,
CalendarDays,
Download, Download,
Link,
NotebookPen,
NotebookText,
Pencil, Pencil,
PlusIcon, PlusIcon,
Tags,
Trash2 Trash2
} from '@lucide/svelte'; } 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 { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js'; import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.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 Table from '$lib/components/ui/table/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte'; import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
import { untrack } from 'svelte';
let { data } = $props(); 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(() => { $effect(() => {
book = data.book; book = data.book;
@@ -47,7 +50,20 @@
let deleteFiles = $state(true); let deleteFiles = $state(true);
let fileDeleteDialogOpen = $state(false); let fileDeleteDialogOpen = $state(false);
let fileToDelete = $state<number>(); 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) { function openBookInReader(file: BookFile) {
if (getFileType(file.filename) === 'EPUB') if (getFileType(file.filename) === 'EPUB')
@@ -56,158 +72,271 @@
if (getFileType(file.filename) === 'PDF') if (getFileType(file.filename) === 'PDF')
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer'); 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> </script>
<div class="mt-4 flex flex-1 flex-col items-center"> <div class="flex h-full flex-col overflow-y-auto">
<div class="ml-8 grid max-w-[900px] grid-cols-[minmax(250px,1fr)_2.5fr] gap-8"> <!-- Jacket band: the accent carries the identity, the cover overlaps its edge -->
<!-- Cover image and action buttons --> <!--
<aside class="flex flex-col gap-4"> A contained card, not a full-bleed banner. Breaking out with -mx-4 left it
<a href="/book/{book.id}" class="w-full rounded shadow-lg drop-shadow-lg"> inset on one side and bleeding off the other, and the negative top margin
<BookImage was clipped by the layout's overflow-hidden. rounded-lg is exactly
src="/api/{book.cover_image}" var(--radius), so the Corners setting reaches it.
class="h-full w-full overflow-hidden rounded object-cover" -->
/> <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} <div class="min-w-0 flex-1">
<Progress <h1 class="font-serif text-3xl leading-tight tracking-tight text-balance">
value={book?.progress.percentage} {book.title}
max={1} </h1>
color={book?.progress.completed ? 'bg-green-600' : 'bg-yellow-500'}
class="mt-[-8px] h-2 rounded {book?.progress.completed {#if book.subtitle}
? '[&>div]:bg-green-600' <p class="mt-1 font-serif text-lg italic text-primary-foreground/75">{book.subtitle}</p>
: '[&>div]:bg-yellow-500'}"
/>
{/if} {/if}
</a>
<div class="grid grid-cols-[2fr_1fr] gap-2"> {#if book.series}
<!-- If there are multiple files to choose from --> <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} {#if book.files.length > 1}
<!-- Read button -->
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger class="{buttonVariants({ variant: 'accent' })} drop-shadow-lg"> <DropdownMenu.Trigger class={bandPrimary}>
<BookOpenText /> <BookOpenText class="size-4" />
Read {readLabel}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading> <DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
{#each book.files as file} {#each book.files as file (file.id)}
<DropdownMenu.Item onclick={() => openBookInReader(file)} <DropdownMenu.Item onclick={() => openBookInReader(file)}>
>{getFileType(file.filename)}</DropdownMenu.Item {getFileType(file.filename)}
> </DropdownMenu.Item>
{/each} {/each}
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
<!-- Download Button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger class={bandGhost}>
{...props} <Download class="size-4" />
class="w-full {buttonVariants({ Download
variant: 'outline',
size: 'icon'
})} drop-shadow-lg"
>
<Download />
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading> <DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
{#each book.files as file} {#each book.files as file (file.id)}
<!-- Download a single file -->
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
bookOps.downloadBookFile(book.id, file.id, file.filename)}
>{getFileType(file.filename)}</DropdownMenu.Item
> >
{getFileType(file.filename)}
</DropdownMenu.Item>
{/each} {/each}
<!-- Download all files as zip -->
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => { onclick={() => bookOps.downloadBooks([book.id], `${book.title}.zip`)}
bookOps.downloadBooks([book.id], `${book.title}.zip`);
}}
> >
All (ZIP) All (ZIP)
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{/snippet} {:else if book.files.length === 1}
</Tooltip.Trigger> <button type="button" class={bandPrimary} onclick={() => openBookInReader(book.files[0])}>
<Tooltip.Content side="bottom"> <BookOpenText class="size-4" />
<p>Download</p> {readLabel}
</Tooltip.Content> </button>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
<!-- There is only a single file, render simple buttons -->
<!-- Read button --> <button
<Button type="button"
class="w-full {buttonVariants({ variant: 'accent' })} drop-shadow-lg" class={bandGhost}
onclick={() => openBookInReader(book.files[0])}
>
<BookOpenText class="mr-2 size-4" />
Read
</Button>
<!-- Download button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
onclick={() => onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)} bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
> >
<Download /> <Download class="size-4" />
</Tooltip.Trigger> Download
<Tooltip.Content side="bottom"> </button>
<p>Download</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if} {/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> </div>
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2"> {#if book.tags.length > 0}
<!-- Mark as finished button --> <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.Provider>
<Tooltip.Root> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
class=" {buttonVariants({ class="{buttonVariants({ variant: 'outline', size: 'icon' })} {book.progress
variant: 'outline', ?.completed
size: 'icon' ? 'text-success'
})} w-full drop-shadow-lg {book?.progress?.completed === true
? 'text-green-500'
: ''}" : ''}"
onclick={async () => { onclick={async () => {
if (book?.progress?.completed === true) if (book.progress?.completed) await bookOps.markBooksAsIncomplete([book.id]);
await bookOps.markBooksAsIncomplete([book.id]);
else await bookOps.markBooksAsComplete([book.id]); else await bookOps.markBooksAsComplete([book.id]);
}} }}
> >
<BookOpenCheck /> <BookOpenCheck />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="bottom"> <Tooltip.Content side="bottom">
{#if !book?.progress?.completed} <p>{book.progress?.completed ? 'Mark as not finished' : 'Mark as finished'}</p>
<p>Mark as finished</p>
{:else}
<p>Mark as not finished</p>
{/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
<!-- Add to shelf button --> <!-- Add to shelf -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger> <Tooltip.Trigger>
@@ -215,18 +344,14 @@
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
{...props} {...props}
class="{buttonVariants({ class={buttonVariants({ variant: 'outline', size: 'icon' })}
variant: 'outline',
size: 'icon'
})} w-full drop-shadow-lg"
> >
<Album /> <Album />
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading> <DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
<DropdownMenu.Separator /> {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf}
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1} checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
onclick={async () => { onclick={async () => {
@@ -245,8 +370,9 @@
</DropdownMenu.Group> </DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => createShelfDialogOpen = true} onclick={() => (createShelfDialogOpen = true)}
class="text-muted-foreground "> class="text-muted-foreground"
>
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
New Shelf New Shelf
</DropdownMenu.Item> </DropdownMenu.Item>
@@ -260,7 +386,7 @@
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
<!-- Edit button --> <!-- Edit -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -268,7 +394,7 @@
bookOps.bookToEdit = book; bookOps.bookToEdit = book;
bookOps.editDialogOpen = true; bookOps.editDialogOpen = true;
}} }}
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg" class={buttonVariants({ variant: 'outline', size: 'icon' })}
> >
<Pencil /> <Pencil />
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -278,17 +404,17 @@
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
<!-- Delete button --> <!-- Delete -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg" class={buttonVariants({ variant: 'outline', size: 'icon' })}
onclick={() => { onclick={() => {
bookOps.deleteDialogTitle = `Delete book?`; bookOps.deleteDialogTitle = `Delete book?`;
bookOps.deleteFn = async (deleteFiles: boolean) => { bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles, false); await bookOps.deleteBooks([book.id], deleteFiles, false);
libraryState.activeLibrary!.total!-- libraryState.activeLibrary!.total!--;
bookshelfState.deletedBooks([book]) bookshelfState.deletedBooks([book]);
await history.back(); await history.back();
}; };
bookOps.deleteDialogOpen = true; bookOps.deleteDialogOpen = true;
@@ -302,100 +428,16 @@
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
</div> </div>
</div>
</aside> </aside>
<main class="flex flex-col gap-1"> <!-- Library files -->
<!-- Title, subtitle, and authors --> <section class="min-w-0 md:col-start-1 md:row-start-2">
<h1 class="text-2xl font-semibold">{book.title}</h1> <Accordion.Root type="single" class="w-full rounded-lg border bg-card px-4">
<Accordion.Item value="files">
{#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> <Accordion.Trigger>
<div class="ml-2 flex gap-4"> <div class="flex items-center gap-3">
Library Files Library files
<Badge>{book.files.length}</Badge> <Badge>{book.files.length}</Badge>
</div> </div>
</Accordion.Trigger> </Accordion.Trigger>
@@ -413,14 +455,17 @@
{#each book.files as file (file.id)} {#each book.files as file (file.id)}
<Table.Row> <Table.Row>
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden"> <Table.Cell class="max-w-[300px] min-w-0 overflow-hidden">
<div class="wrap-break-word whitespace-normal">{file.filename}</div> <div class="font-mono text-xs break-words whitespace-normal">
{file.filename}
</div>
</Table.Cell> </Table.Cell>
<Table.Cell>{formatFileSize(file.size)}</Table.Cell> <Table.Cell class="font-mono text-xs tabular-nums">
<Table.Cell>{getFileType(file.filename)}</Table.Cell> {formatFileSize(file.size)}
</Table.Cell>
<Table.Cell><span class="font-mono text-xs">{getFileType(file.filename)}</span></Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<div class="flex justify-end gap-1"> <div class="flex justify-end gap-1">
<!-- Read button --> {#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -428,6 +473,7 @@
variant: 'default', variant: 'default',
size: 'icon' size: 'icon'
})} scale-90" })} scale-90"
onclick={() => openBookInReader(file)}
> >
<BookOpenText /> <BookOpenText />
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -438,7 +484,6 @@
</Tooltip.Provider> </Tooltip.Provider>
{/if} {/if}
<!-- Download button -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -458,8 +503,6 @@
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
<!-- Delete button -->
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus> <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger <Tooltip.Trigger
@@ -474,7 +517,7 @@
> >
<Trash2 /> <Trash2 />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="bottom">Delete File</Tooltip.Content> <Tooltip.Content side="bottom">Delete file</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
</div> </div>
@@ -486,8 +529,8 @@
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
</Accordion.Root> </Accordion.Root>
</section>
</div> </div>
</main>
</div> </div>
</div> </div>
@@ -520,8 +563,10 @@
<ShelfCreateDialog <ShelfCreateDialog
bind:open={createShelfDialogOpen} bind:open={createShelfDialogOpen}
onSubmit={async (name: string) => { onSubmit={async (name: string) => {
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id]) const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [
book.lists.push(bookshelf) book.id
createShelfDialogOpen = false ]);
book.lists.push(bookshelf);
createShelfDialogOpen = false;
}} }}
/> />