feat: rebuild the book page with the jacket layout
This commit is contained in:
@@ -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,158 +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.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
|
||||
>  
|
||||
{/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}
|
||||
<!-- Read button -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="{buttonVariants({ variant: 'accent' })} drop-shadow-lg">
|
||||
<BookOpenText />
|
||||
Read
|
||||
<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}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
>
|
||||
{#each book.files as file (file.id)}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
|
||||
{getFileType(file.filename)}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Download Button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="w-full {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} drop-shadow-lg"
|
||||
>
|
||||
<Download />
|
||||
<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}
|
||||
<!-- Download a single file -->
|
||||
{#each book.files as file (file.id)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>
|
||||
{getFileType(file.filename)}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<!-- Download all files as zip -->
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
bookOps.downloadBooks([book.id], `${book.title}.zip`);
|
||||
}}
|
||||
onclick={() => bookOps.downloadBooks([book.id], `${book.title}.zip`)}
|
||||
>
|
||||
All (ZIP)
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{:else}
|
||||
<!-- There is only a single file, render simple buttons -->
|
||||
{:else if book.files.length === 1}
|
||||
<button type="button" class={bandPrimary} onclick={() => openBookInReader(book.files[0])}>
|
||||
<BookOpenText class="size-4" />
|
||||
{readLabel}
|
||||
</button>
|
||||
|
||||
<!-- 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 -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
<button
|
||||
type="button"
|
||||
class={bandGhost}
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Download />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<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>
|
||||
|
||||
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2">
|
||||
<!-- Mark as finished button -->
|
||||
{#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>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class=" {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg {book?.progress?.completed === true
|
||||
? 'text-green-500'
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} {book.progress
|
||||
?.completed
|
||||
? 'text-success'
|
||||
: ''}"
|
||||
onclick={async () => {
|
||||
if (book?.progress?.completed === true)
|
||||
await bookOps.markBooksAsIncomplete([book.id]);
|
||||
if (book.progress?.completed) 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}
|
||||
<p>{book.progress?.completed ? 'Mark as not finished' : 'Mark as finished'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Add to shelf button -->
|
||||
<!-- Add to shelf -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
@@ -215,18 +344,14 @@
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="{buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg"
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon' })}
|
||||
>
|
||||
<Album />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf}
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
|
||||
onclick={async () => {
|
||||
@@ -245,8 +370,9 @@
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => createShelfDialogOpen = true}
|
||||
class="text-muted-foreground ">
|
||||
onclick={() => (createShelfDialogOpen = true)}
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New Shelf
|
||||
</DropdownMenu.Item>
|
||||
@@ -260,7 +386,7 @@
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Edit button -->
|
||||
<!-- Edit -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -268,7 +394,7 @@
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon' })}
|
||||
>
|
||||
<Pencil />
|
||||
</Tooltip.Trigger>
|
||||
@@ -278,17 +404,17 @@
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete button -->
|
||||
<!-- Delete -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
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])
|
||||
libraryState.activeLibrary!.total!--;
|
||||
bookshelfState.deletedBooks([book]);
|
||||
await history.back();
|
||||
};
|
||||
bookOps.deleteDialogOpen = true;
|
||||
@@ -302,100 +428,16 @@
|
||||
</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
|
||||
>  
|
||||
{/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">
|
||||
<!-- 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="ml-2 flex gap-4">
|
||||
Library Files
|
||||
<div class="flex items-center gap-3">
|
||||
Library files
|
||||
<Badge>{book.files.length}</Badge>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
@@ -413,14 +455,17 @@
|
||||
{#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>
|
||||
<div class="font-mono text-xs break-words whitespace-normal">
|
||||
{file.filename}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatFileSize(file.size)}</Table.Cell>
|
||||
<Table.Cell>{getFileType(file.filename)}</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">
|
||||
<!-- Read button -->
|
||||
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
|
||||
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -428,6 +473,7 @@
|
||||
variant: 'default',
|
||||
size: 'icon'
|
||||
})} scale-90"
|
||||
onclick={() => openBookInReader(file)}
|
||||
>
|
||||
<BookOpenText />
|
||||
</Tooltip.Trigger>
|
||||
@@ -438,7 +484,6 @@
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
|
||||
<!-- Download button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -458,8 +503,6 @@
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete button -->
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
@@ -474,7 +517,7 @@
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">Delete File</Tooltip.Content>
|
||||
<Tooltip.Content side="bottom">Delete file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
@@ -486,8 +529,8 @@
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -520,8 +563,10 @@
|
||||
<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;
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user