diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 638d1d0..e200420 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 }> = { + '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) { + 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'; diff --git a/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte b/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte index 3502e99..1baa965 100644 --- a/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte @@ -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(); - 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'; -
-
- -
@@ -517,11 +560,13 @@ - { - 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; }} />