Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
AudioOutlined,
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
LinkOutlined,
|
||||
ReadOutlined,
|
||||
TabletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { Book, BookFile, Edition, ReadingFormat } from '../../types'
|
||||
import { fetchBook } from '../../api/books'
|
||||
import { assignFile, deleteFile, fetchBookFiles } from '../../api/files'
|
||||
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[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
fetchBook(Number(id))
|
||||
.then(b => {
|
||||
setBook(b)
|
||||
return fetchBookFiles(b.id)
|
||||
})
|
||||
.then(setFiles)
|
||||
.catch(() => setBook(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
)}
|
||||
</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}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 10 }}>
|
||||
Editions from Hardcover ({filtered.length})
|
||||
</Typography.Title>
|
||||
<div className={s.editionList}>
|
||||
{filtered.map(ed => (
|
||||
<EditionRow key={ed.id} edition={ed} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Files */}
|
||||
<section className={s.section}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 10 }}>
|
||||
Files {files.length > 0 && `(${files.length})`}
|
||||
</Typography.Title>
|
||||
{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}
|
||||
onUnlink={handleUnlink}
|
||||
onDelete={handleDeleteFile}
|
||||
/>
|
||||
))}
|
||||
</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 FileRow({
|
||||
file,
|
||||
onUnlink,
|
||||
onDelete,
|
||||
}: {
|
||||
file: BookFile
|
||||
onUnlink: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}) {
|
||||
return (
|
||||
<Flex gap={12} align="center" className={s.editionRow}>
|
||||
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 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>
|
||||
<Flex gap={4}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => onUnlink(file.id)}
|
||||
title="Unlink from book"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user