Added support for bittorrent
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { Download, TorrentSearchResult } from '../types'
|
||||
|
||||
export async function fetchDownloads(): Promise<Download[]> {
|
||||
const res = await fetch('/api/downloads')
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function addDownload(magnet: string, bookId?: number): Promise<Download> {
|
||||
const res = await fetch('/api/downloads', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ magnet, bookId: bookId ?? null }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cancelDownload(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/downloads/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
}
|
||||
|
||||
export async function searchTorrents(q: string, type: 'ebook' | 'audiobook' = 'ebook'): Promise<TorrentSearchResult[]> {
|
||||
const res = await fetch(`/api/search/torrents?q=${encodeURIComponent(q)}&type=${type}`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
@@ -23,3 +23,15 @@ export function deleteFile(id: number): Promise<void> {
|
||||
export function triggerScan(): Promise<void> {
|
||||
return fetch('/api/scan', { method: 'POST' }).then(() => undefined)
|
||||
}
|
||||
|
||||
export function organizeFile(id: number): Promise<{ moved: boolean; newRelativePath: string; newFilename: string; skipReason: string | null }> {
|
||||
return fetch(`/api/files/${id}/organize`, { method: 'POST' }).then(r => r.json())
|
||||
}
|
||||
|
||||
export function writeFileMetadata(id: number): Promise<{ success: boolean; message: string }> {
|
||||
return fetch(`/api/files/${id}/write-metadata`, { method: 'POST' }).then(r => r.json())
|
||||
}
|
||||
|
||||
export function writeBookMetadata(bookId: number): Promise<{ results: { fileId: number; filename: string; success: boolean; message: string }[] }> {
|
||||
return fetch(`/api/books/${bookId}/write-metadata`, { method: 'POST' }).then(r => r.json())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TorrentSearchResult, WantedBook } from '../types'
|
||||
|
||||
export async function fetchWanted(): Promise<WantedBook[]> {
|
||||
const res = await fetch('/api/wanted')
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function addWanted(bookId: number, formatPreference?: string, minSeeders = 1): Promise<WantedBook> {
|
||||
const res = await fetch('/api/wanted', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookId, formatPreference: formatPreference ?? null, minSeeders }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function removeWanted(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/wanted/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
}
|
||||
|
||||
export async function searchNow(id: number): Promise<TorrentSearchResult[]> {
|
||||
const res = await fetch(`/api/wanted/${id}/search-now`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
@@ -72,6 +72,26 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fileBadges {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fileBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(0,0,0,.55);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 10px 10px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Tag } from 'antd'
|
||||
import type { Book } from '../../types'
|
||||
import type { Book, FileFormat } from '../../types'
|
||||
import s from './BookCard.module.css'
|
||||
|
||||
const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf']
|
||||
const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac']
|
||||
|
||||
interface Props {
|
||||
book: Book
|
||||
onClick: (book: Book) => void
|
||||
@@ -16,6 +18,9 @@ export default function BookCard({ book, onClick, selected }: Props) {
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
const hasEbook = book.localFileFormats.some(f => EBOOK_FORMATS.includes(f))
|
||||
const hasAudiobook = book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f))
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${s.card} ${selected ? s.selected : ''}`}
|
||||
@@ -29,21 +34,22 @@ export default function BookCard({ book, onClick, selected }: Props) {
|
||||
{book.series && (
|
||||
<span className={s.seriesPill}>#{book.series.position}</span>
|
||||
)}
|
||||
{(hasEbook || hasAudiobook) && (
|
||||
<span className={s.fileBadges}>
|
||||
{hasEbook && <span className={s.fileBadge} title="Ebook in library">📖</span>}
|
||||
{hasAudiobook && <span className={s.fileBadge} title="Audiobook in library">🎧</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
<p className={s.title}>{book.title}</p>
|
||||
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
|
||||
<div className={s.meta}>
|
||||
{book.year && <span className={s.year}>{book.year}</span>}
|
||||
<div className={s.chips}>
|
||||
{book.formats.map(f => (
|
||||
<Tag key={f} style={{ fontSize: 11, lineHeight: '20px', padding: '0 5px', margin: 0 }}>
|
||||
{f.toUpperCase()}
|
||||
</Tag>
|
||||
))}
|
||||
{book.year && (
|
||||
<div className={s.meta}>
|
||||
<span className={s.year}>{book.year}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.stateLayer} />
|
||||
|
||||
@@ -15,10 +15,12 @@ function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
formats: ['epub'],
|
||||
color: '#6366f1',
|
||||
genres: ['Science Fiction'],
|
||||
authors: [{ id: 1, name: 'Frank Herbert' }],
|
||||
authors: [{ id: 1, name: 'Frank Herbert', bio: null, bornYear: null, imageUrl: null, slug: null, role: 'Author' }],
|
||||
coverUrl: null,
|
||||
isbn: null,
|
||||
hardcoverId: null,
|
||||
editions: [],
|
||||
localFileFormats: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -34,10 +36,20 @@ describe('BookCard', () => {
|
||||
expect(screen.getByText('Frank Herbert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders format chips', () => {
|
||||
render(<BookCard book={makeBook({ formats: ['epub', 'mobi'] })} onClick={vi.fn()} />)
|
||||
expect(screen.getByText('EPUB')).toBeInTheDocument()
|
||||
expect(screen.getByText('MOBI')).toBeInTheDocument()
|
||||
it('renders ebook badge when ebook file is present', () => {
|
||||
render(<BookCard book={makeBook({ localFileFormats: ['epub'] })} onClick={vi.fn()} />)
|
||||
expect(screen.getByTitle('Ebook in library')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders audiobook badge when audiobook file is present', () => {
|
||||
render(<BookCard book={makeBook({ localFileFormats: ['m4b'] })} onClick={vi.fn()} />)
|
||||
expect(screen.getByTitle('Audiobook in library')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no file badges when no local files', () => {
|
||||
render(<BookCard book={makeBook({ localFileFormats: [] })} onClick={vi.fn()} />)
|
||||
expect(screen.queryByTitle('Ebook in library')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Audiobook in library')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders series position pill when series exists', () => {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge, Button, Flex, Switch, Tag, Typography, Upload } from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Badge, Button, Flex, Input, Modal, Progress, Segmented, Select, Spin, Switch, Tag, Tabs,
|
||||
Typography, message,
|
||||
} from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
FolderOutlined,
|
||||
GlobalOutlined,
|
||||
LinkOutlined,
|
||||
LoadingOutlined,
|
||||
PlusOutlined,
|
||||
ScanOutlined,
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
StarOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { BookFile, QueueItem as IQueueItem, ImportSource } from '../../types'
|
||||
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue'
|
||||
import { fetchUnmatchedFiles, triggerScan } from '../../api/files'
|
||||
import QueueItem from '../../components/QueueItem/QueueItem'
|
||||
import type {
|
||||
Book, BookFile, Download, HardcoverSearchResult, ImportSource, TorrentSearchResult, WantedBook,
|
||||
} from '../../types'
|
||||
import { fetchSources, updateSource } from '../../api/importQueue'
|
||||
import { assignFile, fetchUnmatchedFiles, triggerScan } from '../../api/files'
|
||||
import { fetchBooks } from '../../api/books'
|
||||
import { searchHardcover, addBookFromHardcover } from '../../api/search'
|
||||
import { addDownload, cancelDownload, fetchDownloads, searchTorrents } from '../../api/downloads'
|
||||
import { addWanted, fetchWanted, removeWanted, searchNow } from '../../api/wanted'
|
||||
|
||||
const SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
folder: <FolderOutlined />,
|
||||
@@ -21,16 +37,481 @@ const SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
url: <GlobalOutlined />,
|
||||
}
|
||||
|
||||
export default function Import() {
|
||||
const [queue, setQueue] = useState<IQueueItem[]>([])
|
||||
const [sources, setSources] = useState<ImportSource[]>([])
|
||||
const [unmatched, setUnmatched] = useState<BookFile[]>([])
|
||||
const [scanning, setScanning] = useState(false)
|
||||
function formatBytes(b: number) {
|
||||
if (b === 0) return '—'
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
// ── Downloads tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function DownloadsTab() {
|
||||
const [downloads, setDownloads] = useState<Download[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDownloads().then(setDownloads).finally(() => setLoading(false))
|
||||
const id = setInterval(() => fetchDownloads().then(setDownloads), 10_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
async function handleCancel(id: string) {
|
||||
try {
|
||||
await cancelDownload(id)
|
||||
setDownloads(ds => ds.filter(d => d.id !== id))
|
||||
} catch {
|
||||
message.error('Failed to cancel download.')
|
||||
}
|
||||
}
|
||||
|
||||
const active = downloads.filter(d => d.status === 'downloading' || d.status === 'queued')
|
||||
const finished = downloads.filter(d => d.status === 'completed' || d.status === 'failed')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" style={{ padding: 40 }}>
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
if (downloads.length === 0) {
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ minHeight: 160 }}>
|
||||
<Typography.Text type="secondary">No downloads.</Typography.Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>Active</Typography.Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{active.map(d => (
|
||||
<DownloadRow key={d.id} download={d} onCancel={handleCancel} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{finished.length > 0 && (
|
||||
<section>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>History</Typography.Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{finished.map(d => (
|
||||
<DownloadRow key={d.id} download={d} onCancel={handleCancel} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadRow({ download: d, onCancel }: { download: Download; onCancel: (id: string) => void }) {
|
||||
const pct = d.sizeBytes > 0 ? Math.round((d.downloadedBytes / d.sizeBytes) * 100) : 0
|
||||
const statusColor: Record<string, string> = {
|
||||
downloading: '#6750A4',
|
||||
queued: '#fa8c16',
|
||||
completed: '#52c41a',
|
||||
failed: '#ff4d4f',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
}}>
|
||||
<Flex align="center" gap={8} style={{ marginBottom: d.status === 'downloading' ? 6 : 0 }}>
|
||||
<Tag color={statusColor[d.status]} style={{ margin: 0, flexShrink: 0 }}>{d.status}</Tag>
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
|
||||
{d.bookTitle ?? d.filename}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
|
||||
{formatBytes(d.sizeBytes)}
|
||||
</Typography.Text>
|
||||
{(d.status === 'downloading' || d.status === 'queued') && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
onClick={() => onCancel(d.id)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{d.status === 'downloading' && (
|
||||
<Progress percent={pct} size="small" showInfo={false} strokeColor="#6750A4" />
|
||||
)}
|
||||
{d.status === 'failed' && d.error && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>{d.error}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SearchTab({ initialQuery }: { initialQuery?: string }) {
|
||||
const [query, setQuery] = useState(initialQuery ?? '')
|
||||
const [type, setType] = useState<'ebook' | 'audiobook'>('ebook')
|
||||
const [results, setResults] = useState<TorrentSearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState<string | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery) doSearch(initialQuery, type)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function doSearch(q: string, t: 'ebook' | 'audiobook') {
|
||||
if (!q.trim()) { setResults([]); return }
|
||||
setLoading(true)
|
||||
searchTorrents(q, t)
|
||||
.then(setResults)
|
||||
.catch(() => { message.error('Search failed.'); setResults([]) })
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
function handleQueryChange(q: string) {
|
||||
setQuery(q)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (!q.trim()) { setResults([]); return }
|
||||
timerRef.current = setTimeout(() => doSearch(q, type), 500)
|
||||
}
|
||||
|
||||
function handleTypeChange(t: 'ebook' | 'audiobook') {
|
||||
setType(t)
|
||||
if (query.trim()) doSearch(query, t)
|
||||
}
|
||||
|
||||
async function handleDownload(r: TorrentSearchResult) {
|
||||
if (!r.magnet || adding) return
|
||||
setAdding(r.magnet)
|
||||
try {
|
||||
await addDownload(r.magnet)
|
||||
message.success(`Added "${r.title}" to downloads.`)
|
||||
} catch {
|
||||
message.error('Failed to add download.')
|
||||
} finally {
|
||||
setAdding(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search for ebooks or audiobooks…"
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: 'Ebook', value: 'ebook' },
|
||||
{ label: 'Audiobook', value: 'audiobook' },
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{loading && (
|
||||
<Flex justify="center" style={{ padding: 32 }}>
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!loading && query.trim() && results.length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
No results for "{query}"
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{!loading && !query.trim() && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
Start typing to search indexers
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{!loading && results.map((r, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
}}>
|
||||
<Flex align="flex-start" justify="space-between" gap={12}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>
|
||||
{r.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.seeders} seeders · {r.leechers} leechers · {formatBytes(r.sizeBytes ?? 0)} · {r.indexer}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
type="primary"
|
||||
ghost
|
||||
disabled={!r.magnet || adding === r.magnet}
|
||||
loading={adding === r.magnet}
|
||||
onClick={() => handleDownload(r)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Wanted tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WantedTab() {
|
||||
const [wanted, setWanted] = useState<WantedBook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searching, setSearching] = useState<number | null>(null)
|
||||
const [removing, setRemoving] = useState<number | null>(null)
|
||||
const [searchResults, setSearchResults] = useState<{ id: number; results: TorrentSearchResult[] } | null>(null)
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [addBookId, setAddBookId] = useState<number | null>(null)
|
||||
const [addFormat, setAddFormat] = useState<string | undefined>(undefined)
|
||||
const [addMinSeeders, setAddMinSeeders] = useState(1)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchWanted().then(setWanted).finally(() => setLoading(false))
|
||||
fetchBooks().then(setBooks)
|
||||
}, [])
|
||||
|
||||
async function handleRemove(id: number) {
|
||||
setRemoving(id)
|
||||
try {
|
||||
await removeWanted(id)
|
||||
setWanted(ws => ws.filter(w => w.id !== id))
|
||||
} catch {
|
||||
message.error('Failed to remove.')
|
||||
} finally {
|
||||
setRemoving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearchNow(id: number) {
|
||||
setSearching(id)
|
||||
try {
|
||||
const results = await searchNow(id)
|
||||
setSearchResults({ id, results })
|
||||
} catch {
|
||||
message.error('Search failed.')
|
||||
} finally {
|
||||
setSearching(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!addBookId) return
|
||||
setAdding(true)
|
||||
try {
|
||||
const entry = await addWanted(addBookId, addFormat, addMinSeeders)
|
||||
setWanted(ws => {
|
||||
const idx = ws.findIndex(w => w.bookId === addBookId)
|
||||
if (idx >= 0) { const copy = [...ws]; copy[idx] = entry; return copy }
|
||||
return [...ws, entry]
|
||||
})
|
||||
setAddModalOpen(false)
|
||||
setAddBookId(null)
|
||||
setAddFormat(undefined)
|
||||
setAddMinSeeders(1)
|
||||
} catch {
|
||||
message.error('Failed to add.')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
wanted: '#6750A4',
|
||||
downloading: '#fa8c16',
|
||||
found: '#52c41a',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" style={{ padding: 40 }}>
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="flex-end" style={{ marginBottom: 12 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setAddModalOpen(true)}>
|
||||
Monitor book
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{wanted.length === 0 && (
|
||||
<Flex align="center" justify="center" style={{ minHeight: 120 }}>
|
||||
<Typography.Text type="secondary">No monitored books.</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{wanted.map(w => (
|
||||
<div key={w.id} style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
}}>
|
||||
<Flex align="center" gap={10}>
|
||||
<Tag color={statusColor[w.status]} style={{ margin: 0, flexShrink: 0 }}>{w.status}</Tag>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>
|
||||
{w.bookTitle}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{w.bookAuthors.join(', ')}
|
||||
{w.formatPreference ? ` · ${w.formatPreference}` : ''}
|
||||
{` · ≥${w.minSeeders} seeders`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={searching === w.id ? <LoadingOutlined spin /> : <SearchOutlined />}
|
||||
disabled={searching !== null}
|
||||
onClick={() => handleSearchNow(w.id)}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={removing === w.id ? <LoadingOutlined spin /> : <DeleteOutlined />}
|
||||
disabled={removing !== null}
|
||||
onClick={() => handleRemove(w.id)}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{searchResults?.id === w.id && searchResults.results.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid #f0f0f0', paddingTop: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
{searchResults.results.length} result(s) found:
|
||||
</Typography.Text>
|
||||
{searchResults.results.slice(0, 3).map((r, i) => (
|
||||
<Typography.Text key={i} type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||
· {r.title} ({r.seeders} seeders, {formatBytes(r.sizeBytes ?? 0)})
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchResults?.id === w.id && searchResults.results.length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
|
||||
No results found.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Monitor book"
|
||||
open={addModalOpen}
|
||||
onCancel={() => { setAddModalOpen(false); setAddBookId(null); setAddFormat(undefined); setAddMinSeeders(1) }}
|
||||
onOk={handleAdd}
|
||||
okText="Monitor"
|
||||
okButtonProps={{ disabled: !addBookId, loading: adding }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
||||
<div>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Book</Typography.Text>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Select a book…"
|
||||
value={addBookId ?? undefined}
|
||||
onChange={setAddBookId}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={books.map(b => ({
|
||||
value: b.id,
|
||||
label: `${b.title}${b.authors.length ? ` — ${b.authors[0].name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Format preference (optional)</Typography.Text>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Any format"
|
||||
value={addFormat}
|
||||
onChange={v => setAddFormat(v)}
|
||||
options={[
|
||||
{ label: 'EPUB', value: 'epub' },
|
||||
{ label: 'MOBI', value: 'mobi' },
|
||||
{ label: 'M4B', value: 'm4b' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Minimum seeders</Typography.Text>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={addMinSeeders}
|
||||
onChange={e => setAddMinSeeders(Number(e.target.value) || 1)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sources tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SourcesTab() {
|
||||
const [sources, setSources] = useState<ImportSource[]>([])
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [unmatched, setUnmatched] = useState<BookFile[]>([])
|
||||
|
||||
// Match modal state
|
||||
const [matchingFile, setMatchingFile] = useState<BookFile | null>(null)
|
||||
const [matchTab, setMatchTab] = useState<'library' | 'hardcover'>('library')
|
||||
const [books, setBooks] = useState<Book[]>([])
|
||||
const [bookSearch, setBookSearch] = useState('')
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
const [hcQuery, setHcQuery] = useState('')
|
||||
const [hcResults, setHcResults] = useState<HardcoverSearchResult[]>([])
|
||||
const [hcLoading, setHcLoading] = useState(false)
|
||||
const [hcAdding, setHcAdding] = useState<number | null>(null)
|
||||
const [hcAdded, setHcAdded] = useState<Set<number>>(new Set())
|
||||
const hcTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue().then(setQueue)
|
||||
fetchSources().then(setSources)
|
||||
fetchUnmatchedFiles().then(setUnmatched)
|
||||
fetchBooks().then(setBooks)
|
||||
}, [])
|
||||
|
||||
function handleScan() {
|
||||
@@ -40,16 +521,6 @@ export default function Import() {
|
||||
.finally(() => setScanning(false))
|
||||
}
|
||||
|
||||
function handleRetry(id: string) {
|
||||
retryQueueItem(id).then(() =>
|
||||
setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i))
|
||||
)
|
||||
}
|
||||
|
||||
function handleRemove(id: string) {
|
||||
removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id)))
|
||||
}
|
||||
|
||||
function toggleSource(id: string) {
|
||||
const current = sources.find(s => s.id === id)
|
||||
if (!current) return
|
||||
@@ -60,55 +531,84 @@ export default function Import() {
|
||||
)
|
||||
}
|
||||
|
||||
const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued')
|
||||
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
|
||||
function openMatchModal(file: BookFile) {
|
||||
setMatchingFile(file)
|
||||
setMatchTab('library')
|
||||
setBookSearch('')
|
||||
setHcQuery('')
|
||||
setHcResults([])
|
||||
setHcAdded(new Set())
|
||||
}
|
||||
|
||||
function closeMatchModal() {
|
||||
setMatchingFile(null)
|
||||
setBookSearch('')
|
||||
setHcQuery('')
|
||||
setHcResults([])
|
||||
}
|
||||
|
||||
function handleAssign(book: Book) {
|
||||
if (!matchingFile) return
|
||||
setAssigning(true)
|
||||
assignFile(matchingFile.id, book.id, null)
|
||||
.then(() => {
|
||||
setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id))
|
||||
closeMatchModal()
|
||||
})
|
||||
.finally(() => setAssigning(false))
|
||||
}
|
||||
|
||||
function handleHcQueryChange(q: string) {
|
||||
setHcQuery(q)
|
||||
if (hcTimerRef.current) clearTimeout(hcTimerRef.current)
|
||||
if (!q.trim()) { setHcResults([]); return }
|
||||
hcTimerRef.current = setTimeout(() => {
|
||||
setHcLoading(true)
|
||||
searchHardcover(q)
|
||||
.then(setHcResults)
|
||||
.catch(() => setHcResults([]))
|
||||
.finally(() => setHcLoading(false))
|
||||
}, 400)
|
||||
}
|
||||
|
||||
async function handleHcAddAndMatch(result: HardcoverSearchResult) {
|
||||
if (!matchingFile || hcAdding !== null || hcAdded.has(result.id)) return
|
||||
setHcAdding(result.id)
|
||||
try {
|
||||
const book = await addBookFromHardcover(result.id)
|
||||
setHcAdded(prev => new Set(prev).add(result.id))
|
||||
await assignFile(matchingFile.id, book.id, null)
|
||||
setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id))
|
||||
closeMatchModal()
|
||||
} finally {
|
||||
setHcAdding(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredBooks = useMemo(() => {
|
||||
const q = bookSearch.toLowerCase()
|
||||
if (!q) return books
|
||||
return books.filter(b =>
|
||||
b.title.toLowerCase().includes(q) ||
|
||||
b.authors.some(a => a.name.toLowerCase().includes(q))
|
||||
)
|
||||
}, [books, bookSearch])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Left column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', padding: 24, gap: 24 }}>
|
||||
<section>
|
||||
<Typography.Title level={5} style={{ marginBottom: 12 }}>Drop files</Typography.Title>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
accept=".epub,.mobi,.pdf,.cbz,.cbr"
|
||||
showUploadList={false}
|
||||
beforeUpload={file => {
|
||||
console.log('file:', file.name)
|
||||
return false
|
||||
}}
|
||||
style={{ padding: '8px 0' }}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<UploadOutlined style={{ fontSize: 40, color: 'rgba(0,0,0,.25)', display: 'block', marginBottom: 12 }} />
|
||||
<Typography.Text>Drop EPUB, MOBI, PDF files here</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>or click to browse</Typography.Text>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
</section>
|
||||
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<section>
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Sources</Typography.Title>
|
||||
<div style={{ borderTop: '1px solid #f0f0f0' }}>
|
||||
{sources.map(src => (
|
||||
<div key={src.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 0', borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: '#EDE7F6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6750A4',
|
||||
flexShrink: 0,
|
||||
width: 36, height: 36, borderRadius: '50%', background: '#EDE7F6',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6750A4', flexShrink: 0,
|
||||
}}>
|
||||
{SOURCE_ICONS[src.type] ?? <GlobalOutlined />}
|
||||
</div>
|
||||
@@ -121,16 +621,13 @@ export default function Import() {
|
||||
<Switch
|
||||
checked={src.enabled}
|
||||
onChange={() => toggleSource(src.id)}
|
||||
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
style={{ marginTop: 12 }}
|
||||
type="dashed" icon={<PlusOutlined />} style={{ marginTop: 12 }}
|
||||
onClick={() => {}}
|
||||
>
|
||||
Add source
|
||||
@@ -141,55 +638,10 @@ export default function Import() {
|
||||
<Flex align="center" gap={8} style={{ marginBottom: 8 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>Scan</Typography.Title>
|
||||
</Flex>
|
||||
<Button
|
||||
icon={<ScanOutlined />}
|
||||
loading={scanning}
|
||||
onClick={handleScan}
|
||||
>
|
||||
<Button icon={<ScanOutlined />} loading={scanning} onClick={handleScan}>
|
||||
Scan sources now
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
padding: 24,
|
||||
gap: 24,
|
||||
borderLeft: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<Flex align="center" gap={8} style={{ marginBottom: 12 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>Downloading</Typography.Title>
|
||||
<Badge count={active.length} color="#6750A4" />
|
||||
</Flex>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{active.map(item => (
|
||||
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{finished.length > 0 && (
|
||||
<section>
|
||||
<Typography.Title level={5} style={{ marginBottom: 12 }}>History</Typography.Title>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{finished.map(item => (
|
||||
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{queue.length === 0 && unmatched.length === 0 && (
|
||||
<Flex align="center" justify="center" style={{ flex: 1, minHeight: 120 }}>
|
||||
<Typography.Text type="secondary">No recent activity.</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{unmatched.length > 0 && (
|
||||
<section>
|
||||
@@ -200,28 +652,239 @@ export default function Import() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{unmatched.map(f => (
|
||||
<Flex
|
||||
key={f.id}
|
||||
align="center"
|
||||
gap={10}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
key={f.id} align="center" gap={10}
|
||||
style={{ padding: '8px 12px', border: '1px solid #f0f0f0', borderRadius: 6, background: '#fafafa' }}
|
||||
>
|
||||
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
|
||||
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0, flexShrink: 0 }}>
|
||||
{f.format}
|
||||
</Tag>
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
|
||||
{f.filename}
|
||||
</Typography.Text>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13 }}>{f.filename}</Typography.Text>
|
||||
{f.path !== f.filename && (
|
||||
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 11 }}>
|
||||
{f.path}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Button size="small" icon={<LinkOutlined />} onClick={() => openMatchModal(f)}>
|
||||
Match
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual match modal */}
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<div>Match file to book</div>
|
||||
{matchingFile && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
|
||||
{matchingFile.filename}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={matchingFile !== null}
|
||||
onCancel={closeMatchModal}
|
||||
footer={null}
|
||||
width={520}
|
||||
>
|
||||
<Segmented
|
||||
block
|
||||
value={matchTab}
|
||||
onChange={v => setMatchTab(v as 'library' | 'hardcover')}
|
||||
options={[
|
||||
{ label: 'Library', value: 'library' },
|
||||
{ label: 'Search Hardcover', value: 'hardcover' },
|
||||
]}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{matchTab === 'library' && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search by title or author…"
|
||||
value={bookSearch}
|
||||
onChange={e => setBookSearch(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{filteredBooks.length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ padding: '16px 0', display: 'block', textAlign: 'center' }}>
|
||||
No books found.
|
||||
</Typography.Text>
|
||||
)}
|
||||
{filteredBooks.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
disabled={assigning}
|
||||
onClick={() => handleAssign(b)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||
border: 'none', borderRadius: 6, background: 'transparent',
|
||||
cursor: assigning ? 'not-allowed' : 'pointer', textAlign: 'left', width: '100%',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#f5f0ff')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 48, borderRadius: 4,
|
||||
background: b.coverUrl ? 'transparent' : b.color,
|
||||
flexShrink: 0, overflow: 'hidden',
|
||||
}}>
|
||||
{b.coverUrl && (
|
||||
<img src={b.coverUrl} alt={b.title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Text style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>{b.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{b.authors.map(a => a.name).join(', ')}
|
||||
{b.year ? ` · ${b.year}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{matchTab === 'hardcover' && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search Hardcover…"
|
||||
value={hcQuery}
|
||||
onChange={e => handleHcQueryChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ marginBottom: 12 }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ minHeight: 100, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{hcLoading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
</div>
|
||||
)}
|
||||
{!hcLoading && !hcQuery.trim() && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
Start typing to search Hardcover
|
||||
</Typography.Text>
|
||||
)}
|
||||
{!hcLoading && hcQuery.trim() && hcResults.length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
|
||||
No results for "{hcQuery}"
|
||||
</Typography.Text>
|
||||
)}
|
||||
{!hcLoading && hcResults.map(r => {
|
||||
const isAdded = hcAdded.has(r.id)
|
||||
const isAdding = hcAdding === r.id
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => handleHcAddAndMatch(r)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleHcAddAndMatch(r) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 8px', borderRadius: 6,
|
||||
cursor: isAdded ? 'default' : 'pointer',
|
||||
background: isAdded ? '#f6f0ff' : 'transparent',
|
||||
opacity: isAdding ? 0.6 : 1, transition: 'background 150ms',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)' }}
|
||||
onMouseLeave={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong ellipsis style={{ display: 'block' }}>{r.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ fontSize: 13, display: 'block' }}>
|
||||
{r.authors.join(', ')}{r.year ? ` · ${r.year}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<span style={{ marginLeft: 12, color: isAdded ? '#6750A4' : 'rgba(0,0,0,.45)', fontSize: 18, flexShrink: 0, display: 'flex' }}>
|
||||
{isAdding ? <LoadingOutlined spin /> : isAdded ? <CheckCircleOutlined /> : <PlusOutlined />}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Import() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialTab = searchParams.get('tab') ?? 'downloads'
|
||||
const initialSearch = searchParams.get('q') ?? undefined
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'downloads',
|
||||
label: (
|
||||
<span><DownloadOutlined style={{ marginRight: 6 }} />Downloads</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<DownloadsTab />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
label: (
|
||||
<span><SearchOutlined style={{ marginRight: 6 }} />Search</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<SearchTab initialQuery={initialSearch} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wanted',
|
||||
label: (
|
||||
<span><StarOutlined style={{ marginRight: 6 }} />Wanted</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<WantedTab />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sources',
|
||||
label: (
|
||||
<span><FolderOutlined style={{ marginRight: 6 }} />Sources</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<SourcesTab />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, overflowY: 'auto', height: '100%' }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={items}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface Book {
|
||||
isbn: string | null
|
||||
hardcoverId: number | null
|
||||
editions: Edition[]
|
||||
/** Distinct formats of BookFile records actually present in the library. */
|
||||
localFileFormats: FileFormat[]
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -108,6 +110,49 @@ export interface ImportSource {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed'
|
||||
export type DownloadSourceType = 'manual' | 'torrent' | 'localscan'
|
||||
|
||||
export interface Download {
|
||||
id: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
downloadedBytes: number
|
||||
status: DownloadStatus
|
||||
sourceType: DownloadSourceType
|
||||
torrentHash: string | null
|
||||
bookId: number | null
|
||||
bookTitle: string | null
|
||||
error: string | null
|
||||
addedAt: string
|
||||
}
|
||||
|
||||
export type WantedStatus = 'wanted' | 'downloading' | 'found'
|
||||
export type FileFormatPreference = 'epub' | 'mobi' | 'm4b' | 'mp3' | 'aac' | 'flac'
|
||||
|
||||
export interface WantedBook {
|
||||
id: number
|
||||
bookId: number
|
||||
bookTitle: string
|
||||
bookAuthors: string[]
|
||||
bookCoverUrl: string | null
|
||||
addedAt: string
|
||||
status: WantedStatus
|
||||
formatPreference: FileFormatPreference | null
|
||||
minSeeders: number
|
||||
}
|
||||
|
||||
export interface TorrentSearchResult {
|
||||
title: string
|
||||
magnet: string | null
|
||||
downloadUrl: string | null
|
||||
sizeBytes: number | null
|
||||
seeders: number
|
||||
leechers: number
|
||||
indexer: string
|
||||
publishDate: string | null
|
||||
}
|
||||
|
||||
export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac'
|
||||
|
||||
export interface BookFile {
|
||||
|
||||
Reference in New Issue
Block a user