Files
PageManager/PageManager.Web/src/pages/BookDetail/BookDetail.tsx
T
2026-03-28 17:36:25 +02:00

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>
)
}