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]}`;
|
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
|
||||||
|
>  
|
||||||
|
{/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
|
|
||||||
>  
|
|
||||||
{/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;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user