535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
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<Book | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [files, setFiles] = useState<BookFile[]>([])
|
|
const [editionsOpen, setEditionsOpen] = useState(false)
|
|
const [writingAll, setWritingAll] = useState(false)
|
|
const [wanted, setWanted] = useState<WantedBook | null>(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 (
|
|
<div className={s.page}>
|
|
<div className={s.content}>
|
|
|
|
{/* Back + actions */}
|
|
<Flex align="center" gap={12} className={s.header}>
|
|
<Button
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate('/library')}
|
|
>
|
|
Library
|
|
</Button>
|
|
<div style={{ flex: 1 }} />
|
|
{book && (
|
|
<>
|
|
<Tooltip title={wanted ? 'Monitored — click to unmonitor' : 'Monitor for downloads'}>
|
|
<Button
|
|
icon={wanted ? <StarFilled style={{ color: '#6750A4' }} /> : <StarOutlined />}
|
|
loading={toggling}
|
|
onClick={handleToggleMonitor}
|
|
/>
|
|
</Tooltip>
|
|
<Button
|
|
icon={<SearchOutlined />}
|
|
onClick={() => navigate(`/import?tab=search&q=${encodeURIComponent(book.title)}`)}
|
|
>
|
|
Find Downloads
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<EditOutlined />}
|
|
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
|
|
>
|
|
Edit Metadata
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Flex>
|
|
|
|
{loading && <BookSkeleton />}
|
|
|
|
{!loading && !book && (
|
|
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
|
|
<Typography.Text type="secondary">Book not found.</Typography.Text>
|
|
</Flex>
|
|
)}
|
|
|
|
{!loading && book && (
|
|
<>
|
|
{/* Hero */}
|
|
<Flex gap={36} align="flex-start" className={s.hero}>
|
|
<div className={s.cover} style={{ background: book.color }}>
|
|
{book.coverUrl
|
|
? <img src={book.coverUrl} alt={book.title} className={s.coverImg} />
|
|
: <span className={s.coverInitials}>
|
|
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
<div className={s.info}>
|
|
<Typography.Title level={2} style={{ margin: 0, lineHeight: 1.2 }}>
|
|
{book.title}
|
|
</Typography.Title>
|
|
|
|
<Typography.Text style={{ fontSize: 16, color: 'rgba(0,0,0,.65)' }}>
|
|
{book.authors.map(a => a.name).join(', ')}
|
|
</Typography.Text>
|
|
|
|
{book.series && (
|
|
<Typography.Text style={{ fontSize: 14, color: '#6750A4' }}>
|
|
{book.series.name} · Book {book.series.position}
|
|
{book.series.arc ? ` · ${book.series.arc}` : ''}
|
|
</Typography.Text>
|
|
)}
|
|
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
|
|
<div className={s.stats}>
|
|
{book.year && <StatRow icon={<CalendarOutlined />} label="Year" value={String(book.year)} />}
|
|
{book.pages && <StatRow icon={<FileTextOutlined />} label="Pages" value={String(book.pages)} />}
|
|
{book.publisher && <StatRow icon={<BankOutlined />} label="Publisher" value={book.publisher} />}
|
|
</div>
|
|
|
|
{book.formats.length > 0 && (
|
|
<Flex gap={6} wrap="wrap">
|
|
{book.formats.map(f => (
|
|
<Tag key={f}>{f.toUpperCase()}</Tag>
|
|
))}
|
|
</Flex>
|
|
)}
|
|
|
|
{book.genres.length > 0 && (
|
|
<Flex gap={6} wrap="wrap">
|
|
{book.genres.map(g => (
|
|
<Tag key={g} color="purple">{g}</Tag>
|
|
))}
|
|
</Flex>
|
|
)}
|
|
|
|
{book.localFileFormats.length > 0 && (
|
|
<Flex gap={8} align="center">
|
|
{book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && (
|
|
<Flex gap={6} align="center" style={{
|
|
padding: '3px 10px', borderRadius: 20,
|
|
background: '#f0f5ff', border: '1px solid #adc6ff',
|
|
fontSize: 13, color: '#2f54eb',
|
|
}}>
|
|
<TabletOutlined />
|
|
<span>Ebook</span>
|
|
</Flex>
|
|
)}
|
|
{book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && (
|
|
<Flex gap={6} align="center" style={{
|
|
padding: '3px 10px', borderRadius: 20,
|
|
background: '#f9f0ff', border: '1px solid #d3adf7',
|
|
fontSize: 13, color: '#722ed1',
|
|
}}>
|
|
<AudioOutlined />
|
|
<span>Audiobook</span>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
)}
|
|
</div>
|
|
</Flex>
|
|
|
|
{/* Description */}
|
|
{book.description && (
|
|
<section className={s.section}>
|
|
<Typography.Title level={5} style={{ marginBottom: 10 }}>About</Typography.Title>
|
|
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.75)', marginBottom: 0 }}>
|
|
{book.description}
|
|
</Typography.Paragraph>
|
|
</section>
|
|
)}
|
|
|
|
{/* Editions */}
|
|
{(() => {
|
|
const filtered = book.editions.filter(ed =>
|
|
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
|
|
)
|
|
return filtered.length > 0 ? (
|
|
<section className={s.section}>
|
|
<button
|
|
onClick={() => setEditionsOpen(o => !o)}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
|
marginBottom: editionsOpen ? 10 : 0,
|
|
}}
|
|
>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
Editions from Hardcover ({filtered.length})
|
|
</Typography.Title>
|
|
<DownOutlined
|
|
style={{
|
|
fontSize: 11, color: 'rgba(0,0,0,.45)',
|
|
transition: 'transform .2s',
|
|
transform: editionsOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
}}
|
|
/>
|
|
</button>
|
|
{editionsOpen && (
|
|
<div className={s.editionList}>
|
|
{filtered.map(ed => (
|
|
<EditionRow key={ed.id} edition={ed} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : null
|
|
})()}
|
|
|
|
{/* Files */}
|
|
<section className={s.section}>
|
|
<Flex align="center" gap={8} style={{ marginBottom: 10 }}>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
Files {files.length > 0 && `(${files.length})`}
|
|
</Typography.Title>
|
|
{files.length > 0 && (
|
|
<Tooltip title="Write metadata to all files">
|
|
<Button
|
|
size="small"
|
|
icon={<TagsOutlined />}
|
|
loading={writingAll}
|
|
onClick={handleWriteAllMetadata}
|
|
>
|
|
Write Metadata
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</Flex>
|
|
{files.length === 0 ? (
|
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
|
No files linked to this book.
|
|
</Typography.Text>
|
|
) : (
|
|
<div className={s.editionList}>
|
|
{files.map(f => (
|
|
<FileRow
|
|
key={f.id}
|
|
file={f}
|
|
editions={book.editions.filter(ed =>
|
|
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
|
|
)}
|
|
onUnlink={handleUnlink}
|
|
onDelete={handleDeleteFile}
|
|
onAssignEdition={handleAssignEdition}
|
|
onOrganize={handleOrganize}
|
|
onWriteMetadata={handleWriteMetadata}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
|
return (
|
|
<Flex align="flex-start" gap={10}>
|
|
<span style={{ color: 'rgba(0,0,0,.4)', fontSize: 16, marginTop: 2, display: 'flex' }}>{icon}</span>
|
|
<div>
|
|
<div style={{ fontSize: 11, color: 'rgba(0,0,0,.4)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
|
|
{label}
|
|
</div>
|
|
<div style={{ fontSize: 14, color: 'rgba(0,0,0,.85)' }}>{value}</div>
|
|
</div>
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
|
|
Physical: <ReadOutlined />,
|
|
Audio: <AudioOutlined />,
|
|
Both: <ReadOutlined />,
|
|
Ebook: <TabletOutlined />,
|
|
}
|
|
|
|
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] : <ReadOutlined />
|
|
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 (
|
|
<Flex gap={12} align="flex-start" className={s.editionRow}>
|
|
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 18, display: 'flex', marginTop: 2 }}>{icon}</span>
|
|
<div>
|
|
{label && (
|
|
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>{label}</Typography.Text>
|
|
)}
|
|
{edition.isbn && (
|
|
<Typography.Text
|
|
type="secondary"
|
|
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
|
|
>
|
|
ISBN: {edition.isbn}
|
|
</Typography.Text>
|
|
)}
|
|
{!edition.isbn && edition.asin && (
|
|
<Typography.Text
|
|
type="secondary"
|
|
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
|
|
>
|
|
ASIN: {edition.asin}
|
|
</Typography.Text>
|
|
)}
|
|
{meta.length > 0 && (
|
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
|
{meta.join(' · ')}
|
|
</Typography.Text>
|
|
)}
|
|
</div>
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Flex gap={12} align="center" className={s.editionRow} style={{ flexWrap: 'wrap' }}>
|
|
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0, flexShrink: 0 }}>
|
|
{file.format}
|
|
</Tag>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<Typography.Text
|
|
ellipsis
|
|
style={{ display: 'block', fontSize: 13, fontWeight: 500 }}
|
|
>
|
|
{file.filename}
|
|
</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{formatBytes(file.sizeBytes)}
|
|
</Typography.Text>
|
|
</div>
|
|
{editions.length > 0 && (
|
|
<Select
|
|
size="small"
|
|
allowClear
|
|
placeholder="Edition"
|
|
value={file.editionId ?? undefined}
|
|
onChange={(val: number | undefined) =>
|
|
onAssignEdition(file.id, file.bookId ?? null, val ?? null)
|
|
}
|
|
style={{ width: 200, fontSize: 12 }}
|
|
options={editions.map(ed => ({ value: ed.id, label: editionLabel(ed) }))}
|
|
/>
|
|
)}
|
|
<Flex gap={4}>
|
|
<Tooltip title="Write metadata to file">
|
|
<Button
|
|
size="small"
|
|
icon={<TagsOutlined />}
|
|
loading={writing}
|
|
onClick={handleWrite}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title="Move to canonical library path">
|
|
<Button
|
|
size="small"
|
|
icon={<FolderOutlined />}
|
|
onClick={() => onOrganize(file.id)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title="Unlink from book">
|
|
<Button
|
|
size="small"
|
|
icon={<LinkOutlined />}
|
|
onClick={() => onUnlink(file.id)}
|
|
/>
|
|
</Tooltip>
|
|
<Popconfirm
|
|
title="Remove this file record?"
|
|
description="The file on disk is not deleted."
|
|
onConfirm={() => onDelete(file.id)}
|
|
okText="Remove"
|
|
okType="danger"
|
|
>
|
|
<Button size="small" icon={<DeleteOutlined />} danger title="Remove record" />
|
|
</Popconfirm>
|
|
</Flex>
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
function BookSkeleton() {
|
|
return (
|
|
<Flex gap={36} align="flex-start" style={{ padding: '24px 0' }}>
|
|
<Skeleton.Image active style={{ width: 180, height: 270, borderRadius: 8 }} />
|
|
<div style={{ flex: 1 }}>
|
|
<Skeleton active paragraph={{ rows: 5 }} />
|
|
</div>
|
|
</Flex>
|
|
)
|
|
}
|