Added support for bittorrent

This commit is contained in:
2026-03-28 17:36:25 +02:00
parent 5acde17a53
commit 4f7036ca27
45 changed files with 3383 additions and 225 deletions
+28
View File
@@ -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()
}
+12
View File
@@ -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())
}
+28
View File
@@ -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."
+789 -126
View File
@@ -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>
)
}
+45
View File
@@ -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 {