import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Button, Divider, Flex, Popconfirm, Select, Skeleton, Tag, Tooltip, Typography, message } from 'antd' import { ArrowLeftOutlined, AudioOutlined, BankOutlined, CalendarOutlined, DeleteOutlined, DownOutlined, EditOutlined, FileTextOutlined, FolderOutlined, LinkOutlined, ReadOutlined, SearchOutlined, StarFilled, StarOutlined, TabletOutlined, TagsOutlined, } from '@ant-design/icons' import type { Book, BookFile, Edition, FileFormat, ReadingFormat, WantedBook } from '../../types' const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf'] const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac'] import { fetchBook } from '../../api/books' import { assignFile, deleteFile, fetchBookFiles, organizeFile, writeFileMetadata, writeBookMetadata } from '../../api/files' import { addWanted, fetchWanted, removeWanted } from '../../api/wanted' import s from './BookDetail.module.css' export default function BookDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const [book, setBook] = useState(null) const [loading, setLoading] = useState(true) const [files, setFiles] = useState([]) const [editionsOpen, setEditionsOpen] = useState(false) const [writingAll, setWritingAll] = useState(false) const [wanted, setWanted] = useState(null) const [toggling, setToggling] = useState(false) useEffect(() => { if (!id) return setLoading(true) fetchBook(Number(id)) .then(b => { setBook(b) return Promise.all([ fetchBookFiles(b.id), fetchWanted().then(ws => ws.find(w => w.bookId === b.id) ?? null), ]) }) .then(([fs, w]) => { setFiles(fs); setWanted(w) }) .catch(() => setBook(null)) .finally(() => setLoading(false)) }, [id]) async function handleToggleMonitor() { if (!book) return setToggling(true) try { if (wanted) { await removeWanted(wanted.id) setWanted(null) message.success('Removed from monitoring.') } else { const w = await addWanted(book.id) setWanted(w) message.success('Book is now monitored.') } } catch { message.error('Failed to update monitoring.') } finally { setToggling(false) } } function handleUnlink(fileId: number) { assignFile(fileId, null, null).then(updated => setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) ) } function handleDeleteFile(fileId: number) { deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId))) } function handleAssignEdition(fileId: number, bookId: number | null, editionId: number | null) { assignFile(fileId, bookId, editionId).then(updated => setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) ) } function handleOrganize(fileId: number) { organizeFile(fileId).then(result => { if (result.moved) setFiles(fs => fs.map(f => f.id === fileId ? { ...f, path: result.newRelativePath, filename: result.newFilename } : f )) }) } function handleWriteMetadata(fileId: number) { return writeFileMetadata(fileId).then(r => { if (r.success) message.success(r.message) else message.error(r.message) }) } function handleWriteAllMetadata() { if (!book) return setWritingAll(true) writeBookMetadata(book.id) .then(r => { const failed = r.results.filter(x => !x.success) if (failed.length === 0) message.success(`Metadata written to ${r.results.length} file(s).`) else message.warning(`${r.results.length - failed.length} succeeded, ${failed.length} failed.`) }) .finally(() => setWritingAll(false)) } return (
{/* Back + actions */}
{book && ( <> )} {loading && } {!loading && !book && ( Book not found. )} {!loading && book && ( <> {/* Hero */}
{book.coverUrl ? {book.title} : {book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()} }
{book.title} {book.authors.map(a => a.name).join(', ')} {book.series && ( {book.series.name} · Book {book.series.position} {book.series.arc ? ` · ${book.series.arc}` : ''} )}
{book.year && } label="Year" value={String(book.year)} />} {book.pages && } label="Pages" value={String(book.pages)} />} {book.publisher && } label="Publisher" value={book.publisher} />}
{book.formats.length > 0 && ( {book.formats.map(f => ( {f.toUpperCase()} ))} )} {book.genres.length > 0 && ( {book.genres.map(g => ( {g} ))} )} {book.localFileFormats.length > 0 && ( {book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && ( Ebook )} {book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && ( Audiobook )} )}
{/* Description */} {book.description && (
About {book.description}
)} {/* Editions */} {(() => { const filtered = book.editions.filter(ed => !ed.language || ed.language === 'English' || ed.language === 'Latvian' ) return filtered.length > 0 ? (
{editionsOpen && (
{filtered.map(ed => ( ))}
)}
) : null })()} {/* Files */}
Files {files.length > 0 && `(${files.length})`} {files.length > 0 && ( )} {files.length === 0 ? ( No files linked to this book. ) : (
{files.map(f => ( !ed.language || ed.language === 'English' || ed.language === 'Latvian' )} onUnlink={handleUnlink} onDelete={handleDeleteFile} onAssignEdition={handleAssignEdition} onOrganize={handleOrganize} onWriteMetadata={handleWriteMetadata} /> ))}
)}
)}
) } function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( {icon}
{label}
{value}
) } const FORMAT_ICON: Record = { Physical: , Audio: , Both: , Ebook: , } function formatAudio(seconds: number): string { const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) return h > 0 ? `${h}h ${m}m` : `${m}m` } function EditionRow({ edition }: { edition: Edition }) { const icon = edition.readingFormat ? FORMAT_ICON[edition.readingFormat] : const label = edition.editionFormat ?? edition.readingFormat ?? null const meta: string[] = [] if (edition.publisher) meta.push(edition.publisher) if (edition.releaseYear) meta.push(String(edition.releaseYear)) if (edition.pages) meta.push(`${edition.pages} pp`) if (edition.audioSeconds) meta.push(formatAudio(edition.audioSeconds)) if (edition.language && edition.language !== 'English') meta.push(edition.language) // shows "Latvian" etc. return ( {icon}
{label && ( {label} )} {edition.isbn && ( ISBN: {edition.isbn} )} {!edition.isbn && edition.asin && ( ASIN: {edition.asin} )} {meta.length > 0 && ( {meta.join(' · ')} )}
) } function formatBytes(bytes: number): string { if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB` if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB` if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(0)} KB` return `${bytes} B` } function editionLabel(ed: Edition): string { const parts: string[] = [] if (ed.editionFormat) parts.push(ed.editionFormat) else if (ed.readingFormat) parts.push(ed.readingFormat) if (ed.releaseYear) parts.push(String(ed.releaseYear)) if (ed.isbn) parts.push(`ISBN ${ed.isbn}`) else if (ed.asin) parts.push(`ASIN ${ed.asin}`) return parts.join(' · ') || `Edition #${ed.id}` } function FileRow({ file, editions, onUnlink, onDelete, onAssignEdition, onOrganize, onWriteMetadata, }: { file: BookFile editions: Edition[] onUnlink: (id: number) => void onDelete: (id: number) => void onAssignEdition: (fileId: number, bookId: number | null, editionId: number | null) => void onOrganize: (id: number) => void onWriteMetadata: (id: number) => void }) { const [writing, setWriting] = useState(false) function handleWrite() { setWriting(true) onWriteMetadata(file.id).finally(() => setWriting(false)) } return ( {file.format}
{file.filename} {formatBytes(file.sizeBytes)}
{editions.length > 0 && (