Added support for bittorrent
This commit is contained in:
@@ -1,29 +1,43 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd'
|
||||
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, ReadingFormat } from '../../types'
|
||||
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 } from '../../api/files'
|
||||
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 [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
|
||||
@@ -31,13 +45,36 @@ export default function BookDetail() {
|
||||
fetchBook(Number(id))
|
||||
.then(b => {
|
||||
setBook(b)
|
||||
return fetchBookFiles(b.id)
|
||||
return Promise.all([
|
||||
fetchBookFiles(b.id),
|
||||
fetchWanted().then(ws => ws.find(w => w.bookId === b.id) ?? null),
|
||||
])
|
||||
})
|
||||
.then(setFiles)
|
||||
.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))
|
||||
@@ -48,6 +85,43 @@ export default function BookDetail() {
|
||||
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}>
|
||||
@@ -62,13 +136,28 @@ export default function BookDetail() {
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
{book && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
|
||||
>
|
||||
Edit Metadata
|
||||
</Button>
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -132,6 +221,31 @@ export default function BookDetail() {
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -152,23 +266,55 @@ export default function BookDetail() {
|
||||
)
|
||||
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>
|
||||
<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}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 10 }}>
|
||||
Files {files.length > 0 && `(${files.length})`}
|
||||
</Typography.Title>
|
||||
<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.
|
||||
@@ -179,8 +325,14 @@ export default function BookDetail() {
|
||||
<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>
|
||||
@@ -271,18 +423,42 @@ function formatBytes(bytes: number): string {
|
||||
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}>
|
||||
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
|
||||
<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 }}>
|
||||
@@ -296,13 +472,42 @@ function FileRow({
|
||||
{formatBytes(file.sizeBytes)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Flex gap={4}>
|
||||
<Button
|
||||
{editions.length > 0 && (
|
||||
<Select
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => onUnlink(file.id)}
|
||||
title="Unlink from book"
|
||||
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."
|
||||
|
||||
Reference in New Issue
Block a user