Changed design language. Added editions, better support for authors. Base for file handling

This commit is contained in:
2026-03-28 15:17:20 +02:00
parent cbd7f52535
commit 5acde17a53
84 changed files with 5861 additions and 1983 deletions
+1 -2
View File
@@ -6,8 +6,7 @@
<title>PageManager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+956 -2
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -12,17 +12,19 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"antd": "^6.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.1.1",
"jsdom": "^26.1.0",
"typescript": "~5.7.2",
+1 -20
View File
@@ -1,20 +1 @@
.shell {
display: flex;
height: 100%;
overflow: hidden;
background: var(--md-sys-color-background);
}
.content {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--md-sys-color-surface);
}
.content > * {
flex: 1;
min-height: 0;
}
/* App layout is handled by Ant Design Layout component */
+34 -12
View File
@@ -1,22 +1,44 @@
import { ConfigProvider, Layout } from 'antd'
import { Navigate, Route, Routes } from 'react-router-dom'
import Sidebar from './components/Sidebar/Sidebar'
import Library from './pages/Library/Library'
import BookDetail from './pages/BookDetail/BookDetail'
import Authors from './pages/Authors/Authors'
import AuthorDetail from './pages/AuthorDetail/AuthorDetail'
import Import from './pages/Import/Import'
import Metadata from './pages/Metadata/Metadata'
import s from './App.module.css'
export default function App() {
return (
<div className={s.shell}>
<Sidebar />
<div className={s.content}>
<Routes>
<Route path="/" element={<Navigate to="/library" replace />} />
<Route path="/library" element={<Library />} />
<Route path="/import" element={<Import />} />
<Route path="/metadata" element={<Metadata />} />
</Routes>
</div>
</div>
<ConfigProvider
theme={{
token: {
colorPrimary: '#6750A4',
borderRadius: 8,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
colorBgBase: '#ffffff',
colorBgLayout: '#f5f5f5',
},
components: {
Layout: { siderBg: '#F7F2FA' },
Menu: { itemBg: 'transparent' },
},
}}
>
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sidebar />
<Layout.Content style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Routes>
<Route path="/" element={<Navigate to="/library" replace />} />
<Route path="/library" element={<Library />} />
<Route path="/books/:id" element={<BookDetail />} />
<Route path="/authors" element={<Authors />} />
<Route path="/authors/:id" element={<AuthorDetail />} />
<Route path="/import" element={<Import />} />
<Route path="/metadata" element={<Metadata />} />
</Routes>
</Layout.Content>
</Layout>
</ConfigProvider>
)
}
+10
View File
@@ -0,0 +1,10 @@
import type { AuthorSummary, AuthorDetail } from '../types'
import { api } from './client'
export function fetchAuthors(): Promise<AuthorSummary[]> {
return api.get<AuthorSummary[]>('/authors')
}
export function fetchAuthor(id: number): Promise<AuthorDetail> {
return api.get<AuthorDetail>(`/authors/${id}`)
}
+4
View File
@@ -12,3 +12,7 @@ export function fetchBook(id: number): Promise<Book> {
export function updateBook(id: number, patch: Partial<Book>): Promise<Book> {
return api.put<Book>(`/books/${id}`, patch)
}
export function fetchMetadataFromHardcover(id: number): Promise<Book> {
return api.post<Book>(`/books/${id}/fetch-metadata`, {})
}
+25
View File
@@ -0,0 +1,25 @@
import type { BookFile } from '../types'
export function fetchBookFiles(bookId: number): Promise<BookFile[]> {
return fetch(`/api/books/${bookId}/files`).then(r => r.json())
}
export function fetchUnmatchedFiles(): Promise<BookFile[]> {
return fetch('/api/files?unmatched=true').then(r => r.json())
}
export function assignFile(id: number, bookId: number | null, editionId: number | null): Promise<BookFile> {
return fetch(`/api/files/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookId, editionId }),
}).then(r => r.json())
}
export function deleteFile(id: number): Promise<void> {
return fetch(`/api/files/${id}`, { method: 'DELETE' }).then(() => undefined)
}
export function triggerScan(): Promise<void> {
return fetch('/api/scan', { method: 'POST' }).then(() => undefined)
}
@@ -1,194 +1 @@
/* MD3 Full-screen scrim */
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* MD3 Dialog */
.dialog {
width: 100%;
max-width: 560px;
max-height: 80vh;
background: var(--md-sys-color-surface-container-high);
border-radius: var(--md-sys-shape-xl);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 200ms cubic-bezier(.3,0,0,1);
}
@keyframes slideUp {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 24px 0;
flex-shrink: 0;
}
.heading {
font: var(--md-sys-typescale-headline-small);
color: var(--md-sys-color-on-surface);
flex: 1;
}
/* MD3 Icon Button */
.closeBtn {
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-surface-variant);
position: relative;
overflow: hidden;
}
.closeBtn::before {
content: '';
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity 200ms;
}
.closeBtn:hover::before { opacity: .08; }
.closeBtn:active::before { opacity: .12; }
/* Search field */
.searchWrap {
padding: 16px 24px 12px;
flex-shrink: 0;
}
.search {
display: flex;
align-items: center;
gap: 12px;
height: 52px;
padding: 0 16px;
background: var(--md-sys-color-surface-container-highest);
border-radius: var(--md-sys-shape-full);
}
.searchIcon {
color: var(--md-sys-color-on-surface-variant);
flex-shrink: 0;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
}
.searchInput::placeholder {
color: var(--md-sys-color-on-surface-variant);
}
/* Results list */
.results {
flex: 1;
overflow-y: auto;
padding: 0 8px 16px;
}
.empty {
padding: 32px 16px;
text-align: center;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-large);
}
/* Spinner */
.spinner {
padding: 32px;
display: flex;
justify-content: center;
color: var(--md-sys-color-primary);
}
/* MD3 List Item */
.row {
display: flex;
align-items: center;
padding: 8px 16px;
border-radius: var(--md-sys-shape-md);
gap: 12px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.row::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
}
.row:hover::before { opacity: .08; }
.row:active::before { opacity: .12; }
.rowAdded {
cursor: default;
}
.rowAdded::before { background: var(--md-sys-color-primary); }
.rowAdded:hover::before { opacity: .05; }
.rowContent {
flex: 1;
min-width: 0;
}
.rowTitle {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rowMeta {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.rowAction {
flex-shrink: 0;
color: var(--md-sys-color-on-surface-variant);
font-size: 20px !important;
transition: color 200ms;
}
.rowAdded .rowAction {
color: var(--md-sys-color-primary);
}
.rowLoading {
cursor: default;
pointer-events: none;
}
/* AddBookDialog is implemented with Ant Design Modal */
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { Input, Modal, Spin, Typography } from 'antd'
import { CheckCircleOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
import type { Book, HardcoverSearchResult } from '../../types'
import { searchHardcover, addBookFromHardcover } from '../../api/search'
import s from './AddBookDialog.module.css'
interface Props {
onClose: () => void
@@ -14,11 +15,10 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState<number | null>(null)
const [added, setAdded] = useState<Set<number>>(new Set())
const inputRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<any>(null)
useEffect(() => { inputRef.current?.focus() }, [])
// Debounced search
useEffect(() => {
if (!query.trim()) { setResults([]); return }
const timer = setTimeout(() => {
@@ -31,13 +31,6 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
return () => clearTimeout(timer)
}, [query])
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
async function handleAdd(result: HardcoverSearchResult) {
if (adding !== null || added.has(result.id)) return
setAdding(result.id)
@@ -54,74 +47,85 @@ export default function AddBookDialog({ onClose, onAdded }: Props) {
const showHint = !loading && !query.trim()
return (
<div className={s.scrim} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className={s.dialog} role="dialog" aria-modal="true" aria-label="Add book">
<div className={s.header}>
<span className={s.heading}>Add book</span>
<button className={s.closeBtn} onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined">close</span>
</button>
</div>
<Modal
open
onCancel={onClose}
title="Add book"
footer={null}
width={520}
>
<Input
ref={inputRef}
type="search"
placeholder="Search by title or author…"
value={query}
onChange={e => setQuery(e.target.value)}
allowClear
style={{ marginBottom: 12 }}
/>
<div className={s.searchWrap}>
<div className={s.search}>
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
<input
ref={inputRef}
className={s.searchInput}
type="search"
placeholder="Search by title or author…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div style={{ minHeight: 200, maxHeight: 400, overflowY: 'auto' }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<Spin indicator={<LoadingOutlined spin />} />
</div>
</div>
)}
<div className={s.results}>
{loading && (
<div className={s.spinner}>
<span className="material-symbols-outlined">progress_activity</span>
</div>
)}
{showHint && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
Start typing to search Hardcover
</Typography.Text>
)}
{showHint && (
<p className={s.empty}>Start typing to search Hardcover</p>
)}
{showEmpty && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
No results for "{query}"
</Typography.Text>
)}
{showEmpty && (
<p className={s.empty}>No results for "{query}"</p>
)}
{!loading && results.map(r => {
const isAdded = added.has(r.id)
const isAdding = adding === r.id
{!loading && results.map(r => {
const isAdded = added.has(r.id)
const isAdding = adding === r.id
return (
<div
key={r.id}
className={`${s.row} ${isAdded ? s.rowAdded : ''} ${isAdding ? s.rowLoading : ''}`}
onClick={() => handleAdd(r)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }}
aria-label={`Add ${r.title}`}
>
<div className={s.rowContent}>
<div className={s.rowTitle}>{r.title}</div>
<div className={s.rowMeta}>
{r.authors.join(', ')}
{r.year ? ` · ${r.year}` : ''}
</div>
</div>
<span className={`material-symbols-outlined ${s.rowAction}`}>
{isAdding ? 'progress_activity' : isAdded ? 'check_circle' : 'add'}
</span>
return (
<div
key={r.id}
onClick={() => handleAdd(r)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') handleAdd(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>
)
})}
</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>
</div>
</Modal>
)
}
@@ -1,41 +1,39 @@
/* MD3 Elevated Card */
.card {
display: flex;
flex-direction: column;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-md);
box-shadow: var(--md-sys-elevation-1);
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.06);
overflow: hidden;
cursor: pointer;
position: relative;
transition: box-shadow 200ms cubic-bezier(.2,0,0,1);
transition: box-shadow 200ms, transform 150ms;
outline: none;
}
.card:hover {
box-shadow: var(--md-sys-elevation-2);
box-shadow: 0 4px 12px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.06);
}
.card.selected {
box-shadow: var(--md-sys-elevation-2);
outline: 2px solid var(--md-sys-color-primary);
box-shadow: 0 4px 12px rgba(0,0,0,.12);
outline: 2px solid #6750A4;
outline-offset: -2px;
}
/* State layer for hover/press */
.stateLayer {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: var(--md-sys-color-on-surface);
background: #000;
opacity: 0;
transition: opacity 200ms;
}
.card:hover .stateLayer { opacity: .08; }
.card:active .stateLayer { opacity: .12; }
.card.selected .stateLayer { opacity: .08; background: var(--md-sys-color-primary); }
.card:hover .stateLayer { opacity: .04; }
.card:active .stateLayer { opacity: .08; }
.card.selected .stateLayer { opacity: .04; background: #6750A4; }
.cover {
aspect-ratio: 2/3;
@@ -66,23 +64,39 @@
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,.45);
background: rgba(0,0,0,.5);
color: #fff;
font: var(--md-sys-typescale-label-small);
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: var(--md-sys-shape-xs);
border-radius: 4px;
}
.body {
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 2px;
gap: 3px;
}
.meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.year {
font-size: 11px;
color: rgba(0,0,0,.45);
flex-shrink: 0;
}
.title {
font: var(--md-sys-typescale-title-small);
color: var(--md-sys-color-on-surface);
font-size: 14px;
font-weight: 500;
color: rgba(0,0,0,.85);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -91,8 +105,8 @@
}
.author {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
color: rgba(0,0,0,.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -101,19 +115,5 @@
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
/* MD3 Assist Chip */
.chip {
height: 24px;
padding: 0 8px;
border-radius: var(--md-sys-shape-sm);
border: 1px solid var(--md-sys-color-outline-variant);
font: var(--md-sys-typescale-label-small);
color: var(--md-sys-color-on-surface-variant);
display: flex;
align-items: center;
background: transparent;
gap: 3px;
}
@@ -1,3 +1,4 @@
import { Tag } from 'antd'
import type { Book } from '../../types'
import s from './BookCard.module.css'
@@ -33,14 +34,18 @@ export default function BookCard({ book, onClick, selected }: Props) {
<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.chips}>
{book.formats.map(f => (
<span key={f} className={s.chip}>{f.toUpperCase()}</span>
))}
<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>
))}
</div>
</div>
</div>
{/* MD3 state layer */}
<div className={s.stateLayer} />
</article>
)
@@ -0,0 +1,71 @@
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 16px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: background 150ms;
border-radius: 6px;
margin: 1px 8px;
}
.row:hover {
background: rgba(0, 0, 0, 0.03);
}
.row.selected {
background: #EDE7F6;
}
.stateLayer {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: #000;
opacity: 0;
transition: opacity 150ms;
}
.row:active .stateLayer { opacity: .06; }
.cover {
width: 40px;
height: 56px;
border-radius: 4px;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.coverImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.initials {
font-size: .75rem;
font-weight: 600;
color: rgba(255,255,255,.35);
letter-spacing: .04em;
user-select: none;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.right {
flex-shrink: 0;
}
@@ -0,0 +1,67 @@
import { Flex, Tag, Typography } from 'antd'
import type { Book } from '../../types'
import s from './BookRow.module.css'
interface Props {
book: Book
onClick: (book: Book) => void
selected?: boolean
}
export default function BookRow({ book, onClick, selected }: Props) {
const initials = book.title
.split(' ')
.slice(0, 2)
.map(w => w[0])
.join('')
.toUpperCase()
return (
<div
className={`${s.row} ${selected ? s.selected : ''}`}
onClick={() => onClick(book)}
>
<div className={s.cover} style={{ background: book.color }}>
{book.coverUrl
? <img className={s.coverImg} src={book.coverUrl} alt="" loading="lazy" />
: <span className={s.initials}>{initials}</span>
}
</div>
<div className={s.main}>
<Typography.Text
ellipsis
style={{ display: 'block', fontSize: 14, fontWeight: 500, color: selected ? '#6750A4' : 'rgba(0,0,0,.85)' }}
>
{book.title}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && (
<Typography.Text ellipsis style={{ display: 'block', fontSize: 11, color: '#6750A4', marginTop: 2 }}>
{book.series.name}
<span style={{ color: 'rgba(0,0,0,.45)' }}> · #{book.series.position}</span>
</Typography.Text>
)}
</div>
<Flex align="center" gap={8} className={s.right}>
{book.year && (
<Typography.Text type="secondary" style={{ fontSize: 12, minWidth: 36, textAlign: 'right' }}>
{book.year}
</Typography.Text>
)}
<Flex gap={4}>
{book.formats.map(f => (
<Tag key={f} style={{ margin: 0, fontSize: 11, padding: '0 5px', lineHeight: '20px' }}>
{f.toUpperCase()}
</Tag>
))}
</Flex>
</Flex>
<div className={s.stateLayer} />
</div>
)
}
@@ -1,259 +1 @@
/* MD3 Standard Side Sheet */
.scrim {
display: none; /* hidden on wide screens; modal on narrow */
}
.sheet {
width: 360px;
min-width: 360px;
height: 100%;
background: var(--md-sys-color-surface-container-low);
display: flex;
flex-direction: column;
overflow-y: auto;
transform: translateX(100%);
transition: transform 250ms cubic-bezier(.3,0,0,1);
flex-shrink: 0;
}
.sheet.open {
transform: translateX(0);
}
.header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px 0;
flex-shrink: 0;
}
/* MD3 Icon Button */
.closeBtn {
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-surface-variant);
position: relative;
overflow: hidden;
transition: color 200ms;
}
.closeBtn::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface-variant);
opacity: 0;
transition: opacity 200ms;
}
.closeBtn:hover::before { opacity: .08; }
.closeBtn:active::before { opacity: .12; }
.heading {
font: var(--md-sys-typescale-title-large);
color: var(--md-sys-color-on-surface);
}
.cover {
margin: 12px 16px;
height: 180px;
border-radius: var(--md-sys-shape-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.coverImg {
height: 100%;
width: 100%;
object-fit: contain;
border-radius: var(--md-sys-shape-sm);
}
.coverInitials {
font-size: 3rem;
font-weight: 300;
color: rgba(255,255,255,.3);
letter-spacing: .05em;
}
.body {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
font: var(--md-sys-typescale-headline-small);
color: var(--md-sys-color-on-surface);
margin-top: 4px;
}
.author {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface-variant);
}
.series {
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
/* MD3 Suggestion Chip */
.formatChip {
height: 32px;
padding: 0 12px;
border-radius: var(--md-sys-shape-sm);
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container-highest);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
}
.divider {
height: 1px;
background: var(--md-sys-color-outline-variant);
margin: 4px 0;
}
.stats {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat {
display: flex;
align-items: flex-start;
gap: 12px;
}
.statIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 20px !important;
margin-top: 2px;
}
.statLabel {
font: var(--md-sys-typescale-label-small);
color: var(--md-sys-color-on-surface-variant);
text-transform: uppercase;
letter-spacing: .06em;
}
.statValue {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* MD3 Filter Chip */
.genreChip {
height: 32px;
padding: 0 12px;
border-radius: var(--md-sys-shape-sm);
border: 1px solid var(--md-sys-color-outline-variant);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-on-surface-variant);
display: flex;
align-items: center;
background: transparent;
}
.description {
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface-variant);
line-height: 1.6;
padding-top: 4px;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
/* MD3 Filled Button */
.btnFilled {
height: 40px;
padding: 0 24px;
border-radius: var(--md-sys-shape-full);
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font: var(--md-sys-typescale-label-large);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
transition: box-shadow 200ms;
}
.btnFilled .material-symbols-outlined { font-size: 18px !important; }
.btnFilled::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-primary);
opacity: 0;
transition: opacity 200ms;
}
.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); }
.btnFilled:hover::before { opacity: .08; }
.btnFilled:active::before { opacity: .12; }
/* MD3 Filled Tonal Button */
.btnTonal {
height: 40px;
padding: 0 24px;
border-radius: var(--md-sys-shape-full);
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
font: var(--md-sys-typescale-label-large);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
transition: box-shadow 200ms;
}
.btnTonal .material-symbols-outlined { font-size: 18px !important; }
.btnTonal::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-secondary-container);
opacity: 0;
transition: opacity 200ms;
}
.btnTonal:hover { box-shadow: var(--md-sys-elevation-1); }
.btnTonal:hover::before { opacity: .08; }
.btnTonal:active::before { opacity: .12; }
/* DetailPanel is implemented with Ant Design Drawer */
@@ -1,97 +1,198 @@
import type { Book } from '../../types'
import s from './DetailPanel.module.css'
import { Button, Divider, Drawer, Flex, Space, Tag, Typography } from 'antd'
import {
BankOutlined,
CalendarOutlined,
EditOutlined,
FileTextOutlined,
ReadOutlined,
TabletOutlined,
AudioOutlined,
} from '@ant-design/icons'
import type { Book, Edition, ReadingFormat } from '../../types'
interface Props {
book: Book | null
onClose: () => void
onEditMetadata?: (book: Book) => void
}
export default function DetailPanel({ book, onClose }: Props) {
export default function DetailPanel({ book, onClose, onEditMetadata }: Props) {
return (
<>
{book && <div className={s.scrim} onClick={onClose} />}
<aside className={`${s.sheet} ${book ? s.open : ''}`}>
{book && (
<>
<div className={s.header}>
<button className={s.closeBtn} onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined">close</span>
</button>
<h2 className={s.heading}>Book details</h2>
</div>
<div className={s.cover} style={{ background: book.color }}>
{book.coverUrl
? <img className={s.coverImg} src={book.coverUrl} alt={book.title} />
: <span className={s.coverInitials}>
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span>
}
</div>
<div className={s.body}>
<h3 className={s.title}>{book.title}</h3>
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
<Drawer
open={!!book}
onClose={onClose}
width={340}
title="Book details"
placement="right"
styles={{
header: { borderBottom: '1px solid #f0f0f0', padding: '12px 20px' },
body: { padding: 0, overflowY: 'auto' },
}}
extra={
book && (
<Space>
<Button disabled icon={<ReadOutlined />} size="small">Open</Button>
<Button
type="primary"
icon={<EditOutlined />}
size="small"
onClick={() => onEditMetadata?.(book)}
>
Edit Metadata
</Button>
</Space>
)
}
>
{book && (
<>
{/* Cover */}
<div style={{
height: 190,
background: book.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}>
{book.coverUrl
? <img
src={book.coverUrl}
alt={book.title}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
: <span style={{ fontSize: '3rem', fontWeight: 300, color: 'rgba(255,255,255,.3)', letterSpacing: '.05em' }}>
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span>
}
</div>
{/* Body */}
<div style={{ padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.3 }}>{book.title}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && (
<p className={s.series}>
{book.series.name} · Book {book.series.position}
{book.series.arc ? ` · ${book.series.arc}` : ''}
</p>
)}
<div className={s.chips}>
{book.formats.map(f => (
<span key={f} className={s.formatChip}>{f.toUpperCase()}</span>
))}
</div>
<div className={s.divider} />
<div className={s.stats}>
{book.year && <Stat icon="calendar_today" label="Year" value={String(book.year)} />}
{book.pages && <Stat icon="menu_book" label="Pages" value={String(book.pages)} />}
{book.publisher && <Stat icon="business" label="Publisher" value={book.publisher} />}
</div>
{book.genres.length > 0 && (
<div className={s.genres}>
{book.genres.map(g => (
<span key={g} className={s.genreChip}>{g}</span>
))}
<div style={{ marginTop: 4 }}>
<Typography.Text style={{ fontSize: 13, color: '#6750A4' }}>
{book.series.name} · Book {book.series.position}
{book.series.arc ? ` · ${book.series.arc}` : ''}
</Typography.Text>
</div>
)}
{book.description && (
<p className={s.description}>{book.description}</p>
)}
<div className={s.actions}>
<button className={s.btnFilled}>
<span className="material-symbols-outlined">menu_book</span>
Open
</button>
<button className={s.btnTonal}>
<span className="material-symbols-outlined">edit</span>
Edit Metadata
</button>
</div>
</div>
</>
)}
</aside>
</>
<Flex gap={6} wrap="wrap">
{book.formats.map(f => (
<Tag key={f}>{f.toUpperCase()}</Tag>
))}
</Flex>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{book.year && <StatRow icon={<CalendarOutlined />} label="Year" value={String(book.year)} />}
{book.pages && <StatRow icon={<FileTextOutlined />} label="Pages" value={String(book.pages)} />}
{book.publisher && <StatRow icon={<BankOutlined />} label="Publisher" value={book.publisher} />}
</div>
{book.genres.length > 0 && (
<Flex gap={6} wrap="wrap">
{book.genres.map(g => (
<Tag key={g} color="purple">{g}</Tag>
))}
</Flex>
)}
{book.description && (
<Typography.Paragraph style={{ fontSize: 13, color: 'rgba(0,0,0,.65)', lineHeight: 1.6, marginBottom: 0 }}>
{book.description}
</Typography.Paragraph>
)}
{book.editions.length > 0 && (
<div>
<Typography.Text
type="secondary"
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.06em' }}
>
Editions ({book.editions.length})
</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{book.editions.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
</div>
)}
</div>
</>
)}
</Drawer>
)
}
function Stat({ icon, label, value }: { icon: string; label: string; value: string }) {
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className={s.stat}>
<span className={`material-symbols-outlined ${s.statIcon}`}>{icon}</span>
<Flex align="flex-start" gap={10}>
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 16, marginTop: 1, display: 'flex' }}>{icon}</span>
<div>
<p className={s.statLabel}>{label}</p>
<p className={s.statValue}>{value}</p>
<div style={{ fontSize: 11, color: 'rgba(0,0,0,.45)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
{label}
</div>
<div style={{ fontSize: 14, color: 'rgba(0,0,0,.85)' }}>{value}</div>
</div>
</div>
</Flex>
)
}
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
function formatAudio(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function EditionRow({ edition }: { edition: Edition }) {
const icon = edition.readingFormat ? FORMAT_ICON[edition.readingFormat] : <ReadOutlined />
const label = edition.editionFormat ?? edition.readingFormat ?? null
const details: string[] = []
if (edition.publisher) details.push(edition.publisher)
if (edition.releaseYear) details.push(String(edition.releaseYear))
if (edition.pages) details.push(`${edition.pages} pp`)
if (edition.audioSeconds) details.push(formatAudio(edition.audioSeconds))
if (edition.language && edition.language !== 'English') details.push(edition.language)
return (
<Flex gap={8} align="flex-start">
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 16, display: 'flex', marginTop: 2 }}>{icon}</span>
<div>
{label && (
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>{label}</Typography.Text>
)}
{(edition.isbn || edition.asin) && (
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace', display: 'block' }}
>
{edition.isbn ?? edition.asin}
</Typography.Text>
)}
{details.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{details.join(' · ')}
</Typography.Text>
)}
</div>
</Flex>
)
}
@@ -1,224 +1 @@
.form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 24px 24px;
overflow-y: auto;
flex: 1;
}
.row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.field {
min-width: 0;
}
.fieldFull {
flex: 1;
}
/* ── MD3 Outlined Text Field ── */
.inputWrap {
position: relative;
height: 56px;
}
.textareaWrap {
height: auto;
}
.input {
width: 100%;
height: 100%;
padding: 16px;
background: transparent;
border: none;
border-radius: var(--md-sys-shape-xs);
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
outline: none;
position: relative;
z-index: 1;
}
.textarea {
height: auto;
resize: vertical;
padding-top: 20px;
}
/* The visible border is the fieldset */
.fieldset {
position: absolute;
inset: -5px 0 0;
border: 1px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-xs);
pointer-events: none;
margin: 0;
padding: 0 8px;
transition: border-color 200ms, border-width 200ms;
}
.legend {
font-size: .75rem;
line-height: 0;
padding: 0;
width: 0; /* collapsed by default; expands on focus/filled */
overflow: hidden;
white-space: nowrap;
transition: width 200ms cubic-bezier(.2,0,0,1);
visibility: hidden;
}
.label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface-variant);
pointer-events: none;
transition: top 150ms cubic-bezier(.2,0,0,1),
font-size 150ms cubic-bezier(.2,0,0,1),
line-height 150ms cubic-bezier(.2,0,0,1),
color 150ms;
z-index: 2;
background: transparent;
}
.labelTextarea {
top: 20px;
transform: none;
}
/* Floating label when input has value or is focused */
.input:focus ~ .label,
.input:not(:placeholder-shown) ~ .label {
top: 0;
transform: translateY(-50%);
font-size: .75rem;
line-height: 1rem;
background: var(--md-sys-color-surface-container-low);
padding: 0 4px;
left: 12px;
}
.input:focus ~ .label {
color: var(--md-sys-color-primary);
}
.input:focus ~ .fieldset {
border-color: var(--md-sys-color-primary);
border-width: 2px;
}
.input:focus ~ .fieldset .legend,
.input:not(:placeholder-shown) ~ .fieldset .legend {
width: auto;
padding: 0 2px;
}
.textarea:focus ~ .label,
.textarea:not(:placeholder-shown) ~ .label {
top: 0;
transform: translateY(-50%);
font-size: .75rem;
line-height: 1rem;
background: var(--md-sys-color-surface-container-low);
padding: 0 4px;
left: 12px;
}
.textarea:focus ~ .label {
color: var(--md-sys-color-primary);
}
.textarea:focus ~ .fieldset {
border-color: var(--md-sys-color-primary);
border-width: 2px;
}
.supporting {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
padding: 4px 16px 0;
}
/* ── Buttons ── */
.footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 8px;
}
/* MD3 Outlined Button */
.btnOutlined {
height: 40px;
padding: 0 24px;
border-radius: var(--md-sys-shape-full);
border: 1px solid var(--md-sys-color-outline);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
display: flex;
align-items: center;
gap: 8px;
position: relative;
overflow: hidden;
background: transparent;
transition: box-shadow 200ms;
}
.btnOutlined .material-symbols-outlined { font-size: 18px !important; }
.btnOutlined::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.btnOutlined:hover::before { opacity: .08; }
.btnOutlined:active::before { opacity: .12; }
/* MD3 Filled Button */
.btnFilled {
height: 40px;
padding: 0 24px;
border-radius: var(--md-sys-shape-full);
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font: var(--md-sys-typescale-label-large);
display: flex;
align-items: center;
gap: 8px;
position: relative;
overflow: hidden;
transition: box-shadow 200ms, background 300ms;
min-width: 80px;
justify-content: center;
}
.btnFilled .material-symbols-outlined { font-size: 18px !important; }
.btnFilled::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-primary);
opacity: 0;
transition: opacity 200ms;
}
.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); }
.btnFilled:hover::before { opacity: .08; }
.btnSaved {
background: var(--md-sys-color-success);
}
/* MetadataForm uses Ant Design components with inline styles */
@@ -1,21 +1,26 @@
import { useEffect, useId, useState } from 'react'
import { useEffect, useState } from 'react'
import { Alert, Button, Flex, Input, Space } from 'antd'
import { CheckOutlined, SyncOutlined } from '@ant-design/icons'
import type { Book } from '../../types'
import { toForm } from './utils'
import type { FormState } from './utils'
import s from './MetadataForm.module.css'
interface Props {
book: Book
onSave: (patch: Partial<Book>) => void
onFetchMetadata?: () => Promise<void>
}
export default function MetadataForm({ book, onSave }: Props) {
export default function MetadataForm({ book, onSave, onFetchMetadata }: Props) {
const [form, setForm] = useState<FormState>(() => toForm(book))
const [saved, setSaved] = useState(false)
const [fetching, setFetching] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
useEffect(() => {
setForm(toForm(book))
setSaved(false)
setFetchError(null)
}, [book.id])
const set = (field: keyof FormState) =>
@@ -37,101 +42,104 @@ export default function MetadataForm({ book, onSave }: Props) {
}
return (
<form className={s.form} onSubmit={handleSave}>
<div className={s.row}>
<OutlinedField label="Title" value={form.title} onChange={set('title')} grow />
</div>
<div className={s.row}>
<OutlinedField label="Author(s)" value={form.authors} onChange={set('authors')} grow
supporting="Comma-separated" />
</div>
<div className={s.row}>
<OutlinedField label="Series" value={form.series} onChange={set('series')} grow />
<OutlinedField label="Position" value={form.seriesPosition} onChange={set('seriesPosition')} width={96} type="number" />
</div>
<div className={s.row}>
<OutlinedField label="Publisher" value={form.publisher} onChange={set('publisher')} grow />
<OutlinedField label="Year" value={form.year} onChange={set('year')} width={90} type="number" />
<OutlinedField label="Pages" value={form.pages} onChange={set('pages')} width={90} type="number" />
</div>
<div className={s.row}>
<OutlinedField label="Genres" value={form.genres} onChange={set('genres')} grow
supporting="Comma-separated" />
</div>
<div className={s.row}>
<OutlinedTextarea label="Description" value={form.description} onChange={set('description')} />
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Flex gap={12}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Title</label>
<Input value={form.title} onChange={set('title')} />
</div>
</Flex>
<div>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>
Author(s) <span style={{ fontStyle: 'italic' }}>(comma-separated)</span>
</label>
<Input value={form.authors} onChange={set('authors')} />
</div>
<div className={s.footer}>
<button type="button" className={s.btnOutlined}>
<span className="material-symbols-outlined">sync</span>
Fetch Metadata
</button>
<button type="submit" className={`${s.btnFilled} ${saved ? s.btnSaved : ''}`}>
{saved
? <><span className="material-symbols-outlined">check</span> Saved</>
: 'Save'}
</button>
<Flex gap={12}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Series</label>
<Input value={form.series} onChange={set('series')} />
</div>
<div style={{ width: 96 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Position</label>
<Input type="number" value={form.seriesPosition} onChange={set('seriesPosition')} />
</div>
</Flex>
<Flex gap={12}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Publisher</label>
<Input value={form.publisher} onChange={set('publisher')} />
</div>
<div style={{ width: 90 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Year</label>
<Input type="number" value={form.year} onChange={set('year')} />
</div>
<div style={{ width: 90 }}>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Pages</label>
<Input type="number" value={form.pages} onChange={set('pages')} />
</div>
</Flex>
<div>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>
Genres <span style={{ fontStyle: 'italic' }}>(comma-separated)</span>
</label>
<Input value={form.genres} onChange={set('genres')} />
</div>
<div>
<label style={{ fontSize: 12, color: 'rgba(0,0,0,.45)', display: 'block', marginBottom: 4 }}>Description</label>
<Input.TextArea
value={form.description}
onChange={set('description')}
rows={5}
style={{ resize: 'vertical' }}
/>
</div>
<div style={{ paddingTop: 4, borderTop: '1px solid #f0f0f0' }}>
{fetchError && (
<Alert
message={fetchError}
type="error"
showIcon
closable
onClose={() => setFetchError(null)}
style={{ marginBottom: 12 }}
/>
)}
<Space>
<Button
icon={fetching ? <SyncOutlined spin /> : <SyncOutlined />}
loading={fetching}
disabled={!onFetchMetadata}
onClick={async () => {
if (!onFetchMetadata) return
setFetching(true)
setFetchError(null)
try {
await onFetchMetadata()
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Failed to fetch metadata')
} finally {
setFetching(false)
}
}}
>
{fetching ? 'Fetching…' : 'Fetch Metadata'}
</Button>
<Button
type="primary"
htmlType="submit"
icon={saved ? <CheckOutlined /> : undefined}
>
{saved ? 'Saved' : 'Save'}
</Button>
</Space>
</div>
</form>
)
}
/* ── MD3 Outlined Text Field ─────────────────────────────── */
interface FieldProps {
label: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
grow?: boolean
width?: number
supporting?: string
type?: string
}
function OutlinedField({ label, value, onChange, grow, width, supporting, type = 'text' }: FieldProps) {
const id = useId()
return (
<div className={s.field} style={{ flex: grow ? 1 : undefined, width: width }}>
<div className={s.inputWrap}>
<input
id={id}
className={s.input}
type={type}
value={value}
onChange={onChange}
placeholder=" "
/>
<label htmlFor={id} className={s.label}>{label}</label>
<fieldset className={s.fieldset} aria-hidden><legend className={s.legend}>{label}</legend></fieldset>
</div>
{supporting && <p className={s.supporting}>{supporting}</p>}
</div>
)
}
interface TextareaProps {
label: string
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
function OutlinedTextarea({ label, value, onChange }: TextareaProps) {
const id = useId()
return (
<div className={`${s.field} ${s.fieldFull}`}>
<div className={`${s.inputWrap} ${s.textareaWrap}`}>
<textarea
id={id}
className={`${s.input} ${s.textarea}`}
value={value}
onChange={onChange}
placeholder=" "
rows={5}
/>
<label htmlFor={id} className={`${s.label} ${s.labelTextarea}`}>{label}</label>
<fieldset className={s.fieldset} aria-hidden><legend className={s.legend}>{label}</legend></fieldset>
</div>
</div>
)
}
@@ -1,104 +1 @@
/* MD3 List Item */
.item {
display: flex;
gap: 16px;
padding: 12px 16px;
align-items: flex-start;
border-radius: var(--md-sys-shape-xs);
background: var(--md-sys-color-surface-container-low);
}
.statusIcon {
font-size: 20px !important;
margin-top: 2px;
flex-shrink: 0;
}
.icon_queued { color: var(--md-sys-color-on-surface-variant); }
.icon_downloading { color: var(--md-sys-color-primary); }
.icon_completed { color: var(--md-sys-color-success); }
.icon_failed { color: var(--md-sys-color-error); }
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: baseline;
gap: 8px;
}
.filename {
flex: 1;
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
flex-shrink: 0;
}
.source {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
}
/* MD3 Linear Progress Indicator */
.progressTrack {
height: 4px;
border-radius: 2px;
background: var(--md-sys-color-surface-variant);
overflow: hidden;
margin-top: 4px;
}
.progressBar {
height: 100%;
border-radius: 2px;
background: var(--md-sys-color-primary);
transition: width .4s cubic-bezier(.4,0,.2,1);
}
.error {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-error);
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* MD3 Text Button */
.textBtn {
height: 32px;
padding: 0 8px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
}
.textBtn::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.textBtn:hover::before { opacity: .08; }
.textBtn:active::before { opacity: .12; }
/* QueueItem uses Ant Design components with inline styles */
@@ -1,6 +1,12 @@
import { Button, Flex, Progress, Typography } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ScheduleOutlined,
} from '@ant-design/icons'
import type { QueueItem as IQueueItem } from '../../types'
import { formatBytes } from './utils'
import s from './QueueItem.module.css'
interface Props {
item: IQueueItem
@@ -8,11 +14,11 @@ interface Props {
onRemove: (id: string) => void
}
const STATUS_ICON: Record<IQueueItem['status'], string> = {
queued: 'schedule',
downloading: 'downloading',
completed: 'check_circle',
failed: 'error',
const STATUS_ICON: Record<IQueueItem['status'], React.ReactNode> = {
queued: <ScheduleOutlined style={{ color: 'rgba(0,0,0,.45)' }} />,
downloading: <LoadingOutlined spin style={{ color: '#6750A4' }} />,
completed: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
}
export default function QueueItem({ item, onRetry, onRemove }: Props) {
@@ -20,40 +26,56 @@ export default function QueueItem({ item, onRetry, onRemove }: Props) {
? Math.round((item.downloadedBytes / item.sizeBytes) * 100)
: 0
const sizeLabel = item.status === 'completed'
? formatBytes(item.sizeBytes)
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`
return (
<div className={`${s.item} ${s[`status_${item.status}`]}`}>
<span className={`material-symbols-outlined ${s.statusIcon} ${s[`icon_${item.status}`]}`}>
<div style={{
display: 'flex',
gap: 12,
padding: '10px 12px',
alignItems: 'flex-start',
borderRadius: 6,
background: '#fafafa',
border: '1px solid #f0f0f0',
}}>
<span style={{ fontSize: 18, marginTop: 2, display: 'flex', flexShrink: 0 }}>
{STATUS_ICON[item.status]}
</span>
<div className={s.content}>
<div className={s.row}>
<span className={s.filename}>{item.filename}</span>
<span className={s.size}>
{item.status === 'completed'
? formatBytes(item.sizeBytes)
: `${formatBytes(item.downloadedBytes)} / ${formatBytes(item.sizeBytes)}`}
</span>
</div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Flex align="baseline" gap={8}>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 14 }}>{item.filename}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>{sizeLabel}</Typography.Text>
</Flex>
<span className={s.source}>{item.source}</span>
<Typography.Text type="secondary" ellipsis style={{ fontSize: 12 }}>{item.source}</Typography.Text>
{(item.status === 'downloading' || item.status === 'queued') && (
<div className={s.progressTrack}>
<div className={s.progressBar} style={{ width: `${pct}%` }} />
</div>
<Progress
percent={pct}
size="small"
showInfo={false}
strokeColor="#6750A4"
style={{ margin: '2px 0 0' }}
/>
)}
{item.status === 'failed' && item.error && (
<p className={s.error}>{item.error}</p>
<Typography.Text type="danger" style={{ fontSize: 12 }}>{item.error}</Typography.Text>
)}
<div className={s.actions}>
<Flex gap={4} style={{ marginTop: 2 }}>
{item.status === 'failed' && (
<button className={s.textBtn} onClick={() => onRetry(item.id)}>Retry</button>
<Button size="small" type="link" style={{ padding: 0, height: 'auto' }} onClick={() => onRetry(item.id)}>
Retry
</Button>
)}
<button className={s.textBtn} onClick={() => onRemove(item.id)}>Remove</button>
</div>
<Button size="small" type="link" style={{ padding: 0, height: 'auto' }} onClick={() => onRemove(item.id)}>
Remove
</Button>
</Flex>
</div>
</div>
)
@@ -1,62 +1,101 @@
import { Badge, Layout, Tooltip } from 'antd'
import { NavLink } from 'react-router-dom'
import s from './Sidebar.module.css'
import {
BookOutlined,
DownloadOutlined,
EditOutlined,
SettingOutlined,
ReadOutlined,
TeamOutlined,
} from '@ant-design/icons'
interface NavItem {
to: string
label: string
icon: string
iconFilled: string
badge?: number
}
const NAV: NavItem[] = [
{ to: '/library', label: 'Library', icon: 'library_books', iconFilled: 'library_books', badge: 12 },
{ to: '/import', label: 'Import', icon: 'download', iconFilled: 'download', badge: 2 },
{ to: '/metadata', label: 'Metadata', icon: 'edit_note', iconFilled: 'edit_note' },
const NAV = [
{ to: '/library', label: 'Library', icon: <BookOutlined />, badge: undefined as number | undefined },
{ to: '/authors', label: 'Authors', icon: <TeamOutlined />, badge: undefined },
{ to: '/import', label: 'Import', icon: <DownloadOutlined />, badge: undefined },
{ to: '/metadata', label: 'Metadata', icon: <EditOutlined />, badge: undefined },
]
const rail: React.CSSProperties = {
width: 80,
minWidth: 80,
height: '100%',
background: '#F7F2FA',
borderRight: '1px solid #ede9f2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '12px 0 16px',
overflow: 'hidden',
}
export default function Sidebar() {
return (
<nav className={s.rail}>
<div className={s.brand}>
<span className={`material-symbols-outlined ${s.brandIcon}`}>auto_stories</span>
<Layout.Sider width={80} style={rail}>
{/* Brand */}
<div style={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4 }}>
<ReadOutlined style={{ fontSize: 26, color: '#6750A4' }} />
</div>
<ul className={s.nav}>
{/* Nav items */}
<nav style={{ flex: 1, width: '100%', padding: '0 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
{NAV.map(item => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) => `${s.link} ${isActive ? s.active : ''}`}
>
{({ isActive }) => (
<>
<div className={s.indicator}>
{item.badge !== undefined && (
<span className={s.badge}>{item.badge}</span>
)}
<span
className={`material-symbols-outlined ${s.icon} ${isActive ? s.iconFilled : ''}`}
>
{isActive ? item.iconFilled : item.icon}
<NavLink key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
{({ isActive }) => (
<Tooltip title={item.label} placement="right">
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
padding: '8px 4px',
borderRadius: 8,
background: isActive ? '#E8DEF8' : 'transparent',
cursor: 'pointer',
transition: 'background 150ms',
}}>
<Badge count={item.badge} size="small" offset={[4, -2]}>
<span style={{
fontSize: 20,
color: isActive ? '#21005D' : '#49454F',
display: 'flex',
alignItems: 'center',
}}>
{item.icon}
</span>
</div>
<span className={s.label}>{item.label}</span>
</>
)}
</NavLink>
</li>
</Badge>
<span style={{
fontSize: 11,
lineHeight: 1,
color: isActive ? '#1C1B1F' : '#49454F',
fontWeight: isActive ? 600 : 400,
}}>
{item.label}
</span>
</div>
</Tooltip>
)}
</NavLink>
))}
</ul>
</nav>
<div className={s.footer}>
<button className={s.footerBtn}>
<div className={s.indicator}>
<span className="material-symbols-outlined">settings</span>
{/* Footer */}
<div style={{ width: '100%', padding: '0 8px' }}>
<Tooltip title="Settings" placement="right">
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
padding: '8px 4px',
borderRadius: 8,
cursor: 'pointer',
}}>
<SettingOutlined style={{ fontSize: 20, color: '#49454F' }} />
<span style={{ fontSize: 11, color: '#49454F' }}>Settings</span>
</div>
<span className={s.label}>Settings</span>
</button>
</Tooltip>
</div>
</nav>
</Layout.Sider>
)
}
+1 -89
View File
@@ -1,101 +1,13 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* MD3 Light Purple baseline */
--md-sys-color-primary: #6750A4;
--md-sys-color-on-primary: #FFFFFF;
--md-sys-color-primary-container: #EADDFF;
--md-sys-color-on-primary-container: #21005D;
--md-sys-color-secondary: #625B71;
--md-sys-color-on-secondary: #FFFFFF;
--md-sys-color-secondary-container: #E8DEF8;
--md-sys-color-on-secondary-container: #1D192B;
--md-sys-color-tertiary: #7D5260;
--md-sys-color-on-tertiary: #FFFFFF;
--md-sys-color-tertiary-container: #FFD8E4;
--md-sys-color-on-tertiary-container: #31111D;
--md-sys-color-error: #B3261E;
--md-sys-color-on-error: #FFFFFF;
--md-sys-color-error-container: #F9DEDC;
--md-sys-color-on-error-container: #410E0B;
--md-sys-color-background: #FEF7FF;
--md-sys-color-on-background: #1C1B1F;
--md-sys-color-surface: #FEF7FF;
--md-sys-color-on-surface: #1C1B1F;
--md-sys-color-surface-variant: #E7E0EC;
--md-sys-color-on-surface-variant: #49454F;
--md-sys-color-outline: #79747E;
--md-sys-color-outline-variant: #CAC4D0;
--md-sys-color-surface-container-lowest: #FFFFFF;
--md-sys-color-surface-container-low: #F7F2FA;
--md-sys-color-surface-container: #F3EDF7;
--md-sys-color-surface-container-high: #ECE6F0;
--md-sys-color-surface-container-highest:#E6E0E9;
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #F4EFF4;
--md-sys-color-inverse-primary: #D0BCFF;
--md-sys-color-success: #386A20;
--md-sys-color-success-container: #B7F397;
--md-sys-color-warning: #6E5E00;
--md-sys-color-warning-container: #FBE64B;
/* MD3 Shape */
--md-sys-shape-none: 0px;
--md-sys-shape-xs: 4px;
--md-sys-shape-sm: 8px;
--md-sys-shape-md: 12px;
--md-sys-shape-lg: 16px;
--md-sys-shape-xl: 28px;
--md-sys-shape-full: 50px;
/* MD3 Elevation */
--md-sys-elevation-1: 0px 1px 2px rgba(0,0,0,.3), 0px 1px 3px 1px rgba(0,0,0,.15);
--md-sys-elevation-2: 0px 1px 2px rgba(0,0,0,.3), 0px 2px 6px 2px rgba(0,0,0,.15);
--md-sys-elevation-3: 0px 4px 8px 3px rgba(0,0,0,.15), 0px 1px 3px rgba(0,0,0,.3);
/* Typography */
--md-sys-typescale-body-large: 400 1rem/1.5rem 'Roboto', sans-serif;
--md-sys-typescale-body-medium: 400 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-body-small: 400 .75rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-label-large: 500 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-label-medium:500 .75rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-label-small: 500 .6875rem/1rem 'Roboto', sans-serif;
--md-sys-typescale-title-large: 400 1.375rem/1.75rem 'Roboto', sans-serif;
--md-sys-typescale-title-medium:500 1rem/1.5rem 'Roboto', sans-serif;
--md-sys-typescale-title-small: 500 .875rem/1.25rem 'Roboto', sans-serif;
--md-sys-typescale-headline-small: 400 1.5rem/2rem 'Roboto', sans-serif;
--nav-rail-w: 80px;
}
html, body, #root {
height: 100%;
background: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
font: var(--md-sys-typescale-body-medium);
-webkit-font-smoothing: antialiased;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 24px;
line-height: 1;
user-select: none;
}
button { cursor: pointer; font: inherit; border: none; background: none; color: inherit; }
a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }
input, textarea, select {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
outline: none;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--md-sys-color-outline-variant);
background: rgba(0, 0, 0, 0.15);
border-radius: 2px;
}
@@ -0,0 +1,72 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 960px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.header {
margin-bottom: 36px;
padding-bottom: 32px;
border-bottom: 1px solid #f0f0f0;
}
.avatar {
width: 100px;
height: 100px;
min-width: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,.15);
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarInitials {
font-size: 2rem;
font-weight: 400;
color: rgba(255,255,255,.9);
letter-spacing: .02em;
user-select: none;
}
.authorInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 6px;
}
.section {
margin-bottom: 40px;
}
.seriesGroup {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.seriesGroup:first-of-type {
border-top: none;
padding-top: 12px;
}
.bookGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Flex, Skeleton, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import type { AuthorDetail as IAuthorDetail, Book } from '../../types'
import { fetchAuthor } from '../../api/authors'
import BookCard from '../../components/BookCard/BookCard'
import s from './AuthorDetail.module.css'
export default function AuthorDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [author, setAuthor] = useState<IAuthorDetail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
setLoading(true)
fetchAuthor(Number(id))
.then(setAuthor)
.catch(() => setAuthor(null))
.finally(() => setLoading(false))
}, [id])
// Group books: series → Map<seriesName, Book[]>, standalone
const seriesGroups = useMemo(() => {
if (!author) return []
const map = new Map<string, Book[]>()
for (const book of author.books) {
if (book.series) {
const arr = map.get(book.series.name) ?? []
arr.push(book)
map.set(book.series.name, arr)
}
}
for (const [, books] of map)
books.sort((a, b) => a.series!.position - b.series!.position)
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [author])
const standalone = useMemo(() =>
author?.books
.filter(b => !b.series)
.sort((a, b) => a.title.localeCompare(b.title))
?? [], [author])
const initials = author?.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() ?? ''
const color = avatarColor(author?.id ?? 0)
return (
<div className={s.page}>
<div className={s.content}>
{/* Back */}
<div style={{ marginBottom: 24 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/authors')}>
Authors
</Button>
</div>
{loading && <AuthorSkeleton />}
{!loading && !author && (
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
<Typography.Text type="secondary">Author not found.</Typography.Text>
</Flex>
)}
{!loading && author && (
<>
{/* Author header */}
<Flex gap={28} align="flex-start" className={s.header}>
<div className={s.avatar} style={{ background: color }}>
{author.imageUrl
? <img src={author.imageUrl} alt={author.name} className={s.avatarImg} />
: <span className={s.avatarInitials}>{initials}</span>
}
</div>
<div className={s.authorInfo}>
<Typography.Title level={2} style={{ margin: 0 }}>{author.name}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{author.books.length} {author.books.length === 1 ? 'book' : 'books'}
{author.bornYear ? ` · Born ${author.bornYear}` : ''}
</Typography.Text>
{author.bio && (
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.65)', marginTop: 10, marginBottom: 0, maxWidth: 600 }}>
{author.bio}
</Typography.Paragraph>
)}
</div>
</Flex>
{/* Collections / series */}
{seriesGroups.length > 0 && (
<section className={s.section}>
<Typography.Title level={4} style={{ marginBottom: 0 }}>Collections</Typography.Title>
{seriesGroups.map(([seriesName, books]) => (
<div key={seriesName} className={s.seriesGroup}>
<Flex align="baseline" gap={8} style={{ marginBottom: 10 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{seriesName}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{books.length} {books.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{books.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</div>
))}
</section>
)}
{/* Standalone books */}
{standalone.length > 0 && (
<section className={s.section}>
<Flex align="baseline" gap={8} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Standalone</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{standalone.length} {standalone.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{standalone.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</section>
)}
</>
)}
</div>
</div>
)
}
function avatarColor(id: number): string {
const palette = ['#6750A4', '#7B5EA7', '#5E35B1', '#4527A0', '#9575CD', '#7E57C2']
return palette[id % palette.length]
}
function AuthorSkeleton() {
return (
<Flex gap={28} align="flex-start" style={{ padding: '8px 0 32px' }}>
<Skeleton.Avatar active size={100} />
<div style={{ flex: 1 }}>
<Skeleton active paragraph={{ rows: 3 }} />
</div>
</Flex>
)
}
@@ -0,0 +1,63 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 960px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px;
background: #fff;
border-radius: 10px;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: box-shadow 200ms, border-color 150ms;
}
.card:hover {
box-shadow: 0 4px 14px rgba(0,0,0,.08);
border-color: #d9d9d9;
}
.avatar {
width: 60px;
height: 60px;
min-width: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarInitials {
font-size: 1.25rem;
font-weight: 500;
color: rgba(255,255,255,.9);
letter-spacing: .02em;
user-select: none;
}
.cardBody {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Flex, Input, Typography } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import type { AuthorSummary } from '../../types'
import { fetchAuthors } from '../../api/authors'
import s from './Authors.module.css'
export default function Authors() {
const [authors, setAuthors] = useState<AuthorSummary[]>([])
const [query, setQuery] = useState('')
const navigate = useNavigate()
useEffect(() => { fetchAuthors().then(setAuthors) }, [])
const filtered = useMemo(() =>
authors.filter(a =>
!query || a.name.toLowerCase().includes(query.toLowerCase())
), [authors, query])
return (
<div className={s.page}>
<div className={s.content}>
<Flex align="center" gap={16} style={{ marginBottom: 20 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Authors</Typography.Title>
<Input
placeholder="Search authors…"
value={query}
onChange={e => setQuery(e.target.value)}
allowClear
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary" style={{ marginLeft: 'auto', fontSize: 13 }}>
{filtered.length} authors
</Typography.Text>
</Flex>
<div className={s.grid}>
{filtered.map(author => (
<AuthorCard
key={author.id}
author={author}
onClick={() => navigate(`/authors/${author.id}`)}
/>
))}
{filtered.length === 0 && (
<Typography.Text type="secondary" style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '48px 0', display: 'block' }}>
No authors found.
</Typography.Text>
)}
</div>
</div>
</div>
)
}
function avatarColor(id: number): string {
const palette = ['#6750A4', '#7B5EA7', '#5E35B1', '#4527A0', '#9575CD', '#7E57C2']
return palette[id % palette.length]
}
function AuthorCard({ author, onClick }: { author: AuthorSummary; onClick: () => void }) {
const initials = author.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
const color = avatarColor(author.id)
return (
<div className={s.card} onClick={onClick}>
<div className={s.avatar} style={{ background: color }}>
{author.imageUrl
? <img src={author.imageUrl} alt={author.name} className={s.avatarImg} />
: author.imageUrl === undefined
? <UserOutlined style={{ fontSize: 28, color: 'rgba(255,255,255,.7)' }} />
: <span className={s.avatarInitials}>{initials}</span>
}
</div>
<div className={s.cardBody}>
<Typography.Text strong style={{ fontSize: 14, display: 'block' }}>
{author.name}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{author.bookCount} {author.bookCount === 1 ? 'book' : 'books'}
{author.bornYear ? ` · b. ${author.bornYear}` : ''}
</Typography.Text>
{author.bio && (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ fontSize: 12, color: 'rgba(0,0,0,.55)', marginTop: 6, marginBottom: 0 }}
>
{author.bio}
</Typography.Paragraph>
)}
</div>
</div>
)
}
@@ -0,0 +1,81 @@
.page {
height: 100%;
overflow-y: auto;
}
.content {
max-width: 860px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.header {
margin-bottom: 28px;
}
.hero {
margin-bottom: 32px;
}
.cover {
width: 180px;
min-width: 180px;
aspect-ratio: 2 / 3;
border-radius: 10px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
}
.coverImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.coverInitials {
font-size: 2.5rem;
font-weight: 400;
color: rgba(255,255,255,.3);
letter-spacing: .04em;
user-select: none;
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
.stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.section {
border-top: 1px solid #f0f0f0;
padding-top: 24px;
margin-bottom: 24px;
}
.editionList {
display: flex;
flex-direction: column;
gap: 2px;
}
.editionRow {
padding: 8px 12px;
border-radius: 6px;
transition: background 150ms;
}
.editionRow:hover {
background: rgba(0,0,0,.03);
}
@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd'
import {
ArrowLeftOutlined,
AudioOutlined,
BankOutlined,
CalendarOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
LinkOutlined,
ReadOutlined,
TabletOutlined,
} from '@ant-design/icons'
import type { Book, BookFile, Edition, ReadingFormat } from '../../types'
import { fetchBook } from '../../api/books'
import { assignFile, deleteFile, fetchBookFiles } from '../../api/files'
import s from './BookDetail.module.css'
export default function BookDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [book, setBook] = useState<Book | null>(null)
const [loading, setLoading] = useState(true)
const [files, setFiles] = useState<BookFile[]>([])
useEffect(() => {
if (!id) return
setLoading(true)
fetchBook(Number(id))
.then(b => {
setBook(b)
return fetchBookFiles(b.id)
})
.then(setFiles)
.catch(() => setBook(null))
.finally(() => setLoading(false))
}, [id])
function handleUnlink(fileId: number) {
assignFile(fileId, null, null).then(updated =>
setFiles(fs => fs.map(f => f.id === fileId ? updated : f))
)
}
function handleDeleteFile(fileId: number) {
deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId)))
}
return (
<div className={s.page}>
<div className={s.content}>
{/* Back + actions */}
<Flex align="center" gap={12} className={s.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/library')}
>
Library
</Button>
<div style={{ flex: 1 }} />
{book && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
>
Edit Metadata
</Button>
)}
</Flex>
{loading && <BookSkeleton />}
{!loading && !book && (
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
<Typography.Text type="secondary">Book not found.</Typography.Text>
</Flex>
)}
{!loading && book && (
<>
{/* Hero */}
<Flex gap={36} align="flex-start" className={s.hero}>
<div className={s.cover} style={{ background: book.color }}>
{book.coverUrl
? <img src={book.coverUrl} alt={book.title} className={s.coverImg} />
: <span className={s.coverInitials}>
{book.title.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span>
}
</div>
<div className={s.info}>
<Typography.Title level={2} style={{ margin: 0, lineHeight: 1.2 }}>
{book.title}
</Typography.Title>
<Typography.Text style={{ fontSize: 16, color: 'rgba(0,0,0,.65)' }}>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
{book.series && (
<Typography.Text style={{ fontSize: 14, color: '#6750A4' }}>
{book.series.name} · Book {book.series.position}
{book.series.arc ? ` · ${book.series.arc}` : ''}
</Typography.Text>
)}
<Divider style={{ margin: '12px 0' }} />
<div className={s.stats}>
{book.year && <StatRow icon={<CalendarOutlined />} label="Year" value={String(book.year)} />}
{book.pages && <StatRow icon={<FileTextOutlined />} label="Pages" value={String(book.pages)} />}
{book.publisher && <StatRow icon={<BankOutlined />} label="Publisher" value={book.publisher} />}
</div>
{book.formats.length > 0 && (
<Flex gap={6} wrap="wrap">
{book.formats.map(f => (
<Tag key={f}>{f.toUpperCase()}</Tag>
))}
</Flex>
)}
{book.genres.length > 0 && (
<Flex gap={6} wrap="wrap">
{book.genres.map(g => (
<Tag key={g} color="purple">{g}</Tag>
))}
</Flex>
)}
</div>
</Flex>
{/* Description */}
{book.description && (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>About</Typography.Title>
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.75)', marginBottom: 0 }}>
{book.description}
</Typography.Paragraph>
</section>
)}
{/* Editions */}
{(() => {
const filtered = book.editions.filter(ed =>
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
)
return filtered.length > 0 ? (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Editions from Hardcover ({filtered.length})
</Typography.Title>
<div className={s.editionList}>
{filtered.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
</section>
) : null
})()}
{/* Files */}
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Files {files.length > 0 && `(${files.length})`}
</Typography.Title>
{files.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
No files linked to this book.
</Typography.Text>
) : (
<div className={s.editionList}>
{files.map(f => (
<FileRow
key={f.id}
file={f}
onUnlink={handleUnlink}
onDelete={handleDeleteFile}
/>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
)
}
function StatRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Flex align="flex-start" gap={10}>
<span style={{ color: 'rgba(0,0,0,.4)', fontSize: 16, marginTop: 2, display: 'flex' }}>{icon}</span>
<div>
<div style={{ fontSize: 11, color: 'rgba(0,0,0,.4)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
{label}
</div>
<div style={{ fontSize: 14, color: 'rgba(0,0,0,.85)' }}>{value}</div>
</div>
</Flex>
)
}
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
function formatAudio(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function EditionRow({ edition }: { edition: Edition }) {
const icon = edition.readingFormat ? FORMAT_ICON[edition.readingFormat] : <ReadOutlined />
const label = edition.editionFormat ?? edition.readingFormat ?? null
const meta: string[] = []
if (edition.publisher) meta.push(edition.publisher)
if (edition.releaseYear) meta.push(String(edition.releaseYear))
if (edition.pages) meta.push(`${edition.pages} pp`)
if (edition.audioSeconds) meta.push(formatAudio(edition.audioSeconds))
if (edition.language && edition.language !== 'English') meta.push(edition.language) // shows "Latvian" etc.
return (
<Flex gap={12} align="flex-start" className={s.editionRow}>
<span style={{ color: 'rgba(0,0,0,.45)', fontSize: 18, display: 'flex', marginTop: 2 }}>{icon}</span>
<div>
{label && (
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>{label}</Typography.Text>
)}
{edition.isbn && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
>
ISBN: {edition.isbn}
</Typography.Text>
)}
{!edition.isbn && edition.asin && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, fontFamily: 'monospace', display: 'block' }}
>
ASIN: {edition.asin}
</Typography.Text>
)}
{meta.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{meta.join(' · ')}
</Typography.Text>
)}
</div>
</Flex>
)
}
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(0)} KB`
return `${bytes} B`
}
function FileRow({
file,
onUnlink,
onDelete,
}: {
file: BookFile
onUnlink: (id: number) => void
onDelete: (id: number) => void
}) {
return (
<Flex gap={12} align="center" className={s.editionRow}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
{file.format}
</Tag>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text
ellipsis
style={{ display: 'block', fontSize: 13, fontWeight: 500 }}
>
{file.filename}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatBytes(file.sizeBytes)}
</Typography.Text>
</div>
<Flex gap={4}>
<Button
size="small"
icon={<LinkOutlined />}
onClick={() => onUnlink(file.id)}
title="Unlink from book"
/>
<Popconfirm
title="Remove this file record?"
description="The file on disk is not deleted."
onConfirm={() => onDelete(file.id)}
okText="Remove"
okType="danger"
>
<Button size="small" icon={<DeleteOutlined />} danger title="Remove record" />
</Popconfirm>
</Flex>
</Flex>
)
}
function BookSkeleton() {
return (
<Flex gap={36} align="flex-start" style={{ padding: '24px 0' }}>
<Skeleton.Image active style={{ width: 180, height: 270, borderRadius: 8 }} />
<div style={{ flex: 1 }}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
</Flex>
)
}
@@ -1,213 +1 @@
.page {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
height: 100%;
overflow: hidden;
}
.col {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 24px;
gap: 24px;
}
.col + .col {
border-left: 1px solid var(--md-sys-color-outline-variant);
}
/* MD3 Section heading */
.heading {
font: var(--md-sys-typescale-title-medium);
color: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.headingBadge {
height: 20px;
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font: var(--md-sys-typescale-label-small);
display: flex;
align-items: center;
justify-content: center;
}
/* MD3 Drop Zone — outlined card variant */
.dropzone {
border: 2px dashed var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-md);
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 200ms, background 200ms;
position: relative;
overflow: hidden;
}
.dropzone::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.dropzone:hover::before, .dropzone.dropping::before { opacity: .05; }
.dropzone.dropping { border-color: var(--md-sys-color-primary); }
.dropIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 40px !important;
position: relative;
}
.dropText {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
position: relative;
}
.dropHint {
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface-variant);
position: relative;
}
/* Sources list */
.sourceList {
display: flex;
flex-direction: column;
}
.sourceItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.sourceItem:first-child {
border-top: 1px solid var(--md-sys-color-outline-variant);
}
.sourceLeading {
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-full);
background: var(--md-sys-color-secondary-container);
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-secondary-container);
flex-shrink: 0;
}
.sourceInfo {
flex: 1;
min-width: 0;
}
.sourceName {
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
display: block;
}
.sourcePath {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
/* MD3 Switch */
.switch {
position: relative;
width: 52px;
height: 32px;
border-radius: 16px;
background: var(--md-sys-color-surface-variant);
border: 2px solid var(--md-sys-color-outline);
transition: background 200ms, border-color 200ms;
flex-shrink: 0;
}
.switch.switchOn {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.switchThumb {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--md-sys-color-outline);
top: 50%;
transform: translateY(-50%);
left: 6px;
transition: left 200ms, width 200ms, background 200ms;
}
.switch.switchOn .switchThumb {
left: 26px;
width: 24px;
height: 24px;
top: 50%;
transform: translate(0, -50%);
left: 22px;
background: var(--md-sys-color-on-primary);
}
/* MD3 Text Button */
.addBtn {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
align-self: flex-start;
}
.addBtn::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.addBtn:hover::before { opacity: .08; }
.queueList {
display: flex;
flex-direction: column;
gap: 2px;
}
.empty {
padding: 48px 0;
text-align: center;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-large);
}
/* Import page uses Ant Design components with inline styles */
+154 -76
View File
@@ -1,31 +1,43 @@
import { useEffect, useRef, useState } from 'react'
import type { QueueItem as IQueueItem, ImportSource } from '../../types'
import { useEffect, useState } from 'react'
import { Badge, Button, Flex, Switch, Tag, Typography, Upload } from 'antd'
import {
BookOutlined,
FolderOutlined,
GlobalOutlined,
PlusOutlined,
ScanOutlined,
UploadOutlined,
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 s from './Import.module.css'
const SOURCE_ICONS: Record<string, string> = {
folder: 'folder',
calibre: 'auto_stories',
opds: 'rss_feed',
url: 'language',
const SOURCE_ICONS: Record<string, React.ReactNode> = {
folder: <FolderOutlined />,
calibre: <BookOutlined />,
opds: <WifiOutlined />,
url: <GlobalOutlined />,
}
export default function Import() {
const [queue, setQueue] = useState<IQueueItem[]>([])
const [sources, setSources] = useState<ImportSource[]>([])
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [queue, setQueue] = useState<IQueueItem[]>([])
const [sources, setSources] = useState<ImportSource[]>([])
const [unmatched, setUnmatched] = useState<BookFile[]>([])
const [scanning, setScanning] = useState(false)
useEffect(() => {
fetchQueue().then(setQueue)
fetchSources().then(setSources)
fetchUnmatchedFiles().then(setUnmatched)
}, [])
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
console.log('dropped files:', Array.from(e.dataTransfer.files).map(f => f.name))
function handleScan() {
setScanning(true)
triggerScan()
.then(() => fetchUnmatchedFiles().then(setUnmatched))
.finally(() => setScanning(false))
}
function handleRetry(id: string) {
@@ -52,96 +64,162 @@ export default function Import() {
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
return (
<div className={s.page}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: '100%', overflow: 'hidden' }}>
{/* Left column */}
<div className={s.col}>
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', padding: 24, gap: 24 }}>
<section>
<h2 className={s.heading}>Drop files</h2>
<div
className={`${s.dropzone} ${dragging ? s.dropping : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
<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' }}
>
<span className={`material-symbols-outlined ${s.dropIcon}`}>upload_file</span>
<span className={s.dropText}>Drop EPUB, MOBI, PDF files here</span>
<span className={s.dropHint}>or click to browse</span>
<input
ref={inputRef}
type="file"
accept=".epub,.mobi,.pdf,.cbz,.cbr"
multiple
style={{ display: 'none' }}
onChange={e => console.log('files:', e.target.files)}
/>
</div>
<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>
<section>
<h2 className={s.heading}>Sources</h2>
<ul className={s.sourceList}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>Sources</Typography.Title>
<div style={{ borderTop: '1px solid #f0f0f0' }}>
{sources.map(src => (
<li key={src.id} className={s.sourceItem}>
<div className={s.sourceLeading}>
<span className="material-symbols-outlined">
{SOURCE_ICONS[src.type] ?? 'language'}
</span>
<div key={src.id} style={{
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,
}}>
{SOURCE_ICONS[src.type] ?? <GlobalOutlined />}
</div>
<div className={s.sourceInfo}>
<span className={s.sourceName}>{src.name}</span>
<span className={s.sourcePath}>{src.path}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text style={{ display: 'block', fontSize: 14 }}>{src.name}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>
{src.path}
</Typography.Text>
</div>
<button
className={`${s.switch} ${src.enabled ? s.switchOn : ''}`}
onClick={() => toggleSource(src.id)}
<Switch
checked={src.enabled}
onChange={() => toggleSource(src.id)}
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
>
<span className={s.switchThumb} />
</button>
</li>
size="small"
/>
</div>
))}
</ul>
<button className={s.addBtn}>
<span className="material-symbols-outlined">add</span>
</div>
<Button
type="dashed"
icon={<PlusOutlined />}
style={{ marginTop: 12 }}
onClick={() => {}}
>
Add source
</button>
</Button>
</section>
<section>
<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}
>
Scan sources now
</Button>
</section>
</div>
{/* Right column */}
<div className={s.col}>
<div style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
padding: 24,
gap: 24,
borderLeft: '1px solid #f0f0f0',
}}>
{active.length > 0 && (
<section>
<h2 className={s.heading}>
Downloading
<span className={s.headingBadge}>{active.length}</span>
</h2>
<ul className={s.queueList}>
<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 => (
<li key={item.id}>
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
))}
</ul>
</div>
</section>
)}
{finished.length > 0 && (
<section>
<h2 className={s.heading}>History</h2>
<ul className={s.queueList}>
<Typography.Title level={5} style={{ marginBottom: 12 }}>History</Typography.Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{finished.map(item => (
<li key={item.id}>
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
))}
</ul>
</div>
</section>
)}
{queue.length === 0 && (
<div className={s.empty}>No recent activity.</div>
{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>
<Flex align="center" gap={8} style={{ marginBottom: 12 }}>
<Typography.Title level={5} style={{ margin: 0 }}>Unmatched Files</Typography.Title>
<Badge count={unmatched.length} color="#6750A4" />
</Flex>
<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',
}}
>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
{f.format}
</Tag>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{f.filename}
</Typography.Text>
</Flex>
))}
</div>
</section>
)}
</div>
</div>
@@ -12,174 +12,44 @@
overflow: hidden;
}
/* MD3 Search bar */
.searchWrap {
padding: 16px 16px 8px;
.topBar {
padding: 14px 16px 10px;
flex-shrink: 0;
}
.search {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 16px;
background: var(--md-sys-color-surface-container-high);
border-radius: var(--md-sys-shape-full);
width: 100%;
max-width: 560px;
}
.searchIcon {
color: var(--md-sys-color-on-surface-variant);
flex-shrink: 0;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font: var(--md-sys-typescale-body-large);
color: var(--md-sys-color-on-surface);
}
.searchInput::placeholder {
color: var(--md-sys-color-on-surface-variant);
}
/* MD3 Filter Chips bar */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 12px;
align-items: center;
padding: 0 16px 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.divider {
width: 1px;
height: 20px;
background: var(--md-sys-color-outline-variant);
}
/* MD3 Filter Chip */
.chip {
height: 32px;
padding: 0 12px;
border-radius: var(--md-sys-shape-sm);
border: 1px solid var(--md-sys-color-outline-variant);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-on-surface-variant);
background: transparent;
display: flex;
align-items: center;
gap: 4px;
position: relative;
overflow: hidden;
transition: background 200ms;
cursor: pointer;
}
.chip::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
}
.chip:hover::before { opacity: .08; }
.chipActive {
background: var(--md-sys-color-secondary-container);
border-color: transparent;
color: var(--md-sys-color-on-secondary-container);
}
.chipActive::before { background: var(--md-sys-color-on-secondary-container); }
.countBar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px 4px;
padding: 0 16px 6px;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.count {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
}
/* MD3 Text Button */
.clearBtn {
height: 28px;
padding: 0 8px;
border-radius: var(--md-sys-shape-full);
font: var(--md-sys-typescale-label-large);
color: var(--md-sys-color-primary);
position: relative;
overflow: hidden;
font-size: .75rem !important;
}
.clearBtn::before {
content: '';
position: absolute;
inset: 0;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity 200ms;
}
.clearBtn:hover::before { opacity: .08; }
.grid {
flex: 1;
overflow-y: auto;
padding: 12px 16px 24px;
padding: 12px 16px 80px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
align-content: start;
}
.list {
flex: 1;
overflow-y: auto;
padding: 8px 0 80px;
display: flex;
flex-direction: column;
}
.empty {
grid-column: 1 / -1;
padding: 48px 0;
text-align: center;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-large);
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
/* MD3 FAB */
.fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: var(--md-sys-shape-lg);
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--md-sys-elevation-3);
z-index: 10;
position: fixed;
overflow: hidden;
transition: box-shadow 200ms;
}
.fab::before {
content: '';
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity 200ms;
}
.fab:hover { box-shadow: var(--md-sys-elevation-4); }
.fab:hover::before { opacity: .08; }
.fab:active::before { opacity: .12; }
+140 -72
View File
@@ -1,22 +1,40 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Divider, Flex, FloatButton, Input, Segmented, Select, Space, Tag, Typography } from 'antd'
import { AppstoreOutlined, CloseOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons'
import type { Book, Format } from '../../types'
import { fetchBooks } from '../../api/books'
import { filterBooks } from './utils'
import BookCard from '../../components/BookCard/BookCard'
import DetailPanel from '../../components/DetailPanel/DetailPanel'
import BookRow from '../../components/BookRow/BookRow'
import AddBookDialog from '../../components/AddBookDialog/AddBookDialog'
import s from './Library.module.css'
type ViewMode = 'grid' | 'list'
type SortBy = 'title' | 'author' | 'year'
const ALL_FORMATS: Format[] = ['epub', 'mobi', 'pdf', 'cbz', 'cbr']
export default function Library() {
const [books, setBooks] = useState<Book[]>([])
const [query, setQuery] = useState('')
const [genres, setGenres] = useState<string[]>([])
const [formats, setFormats] = useState<Format[]>([])
const [selected, setSelected] = useState<Book | null>(null)
const [addOpen, setAddOpen] = useState(false)
function sortBooks(books: Book[], by: SortBy): Book[] {
const arr = [...books]
if (by === 'author')
return arr.sort((a, b) =>
(a.authors[0]?.name ?? '').localeCompare(b.authors[0]?.name ?? ''))
if (by === 'year')
return arr.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
return arr.sort((a, b) => a.title.localeCompare(b.title))
}
export default function Library() {
const [books, setBooks] = useState<Book[]>([])
const [query, setQuery] = useState('')
const [genres, setGenres] = useState<string[]>([])
const [formats, setFormats] = useState<Format[]>([])
const [addOpen, setAddOpen] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [sortBy, setSortBy] = useState<SortBy>('title')
const navigate = useNavigate()
const refreshBooks = () => fetchBooks().then(setBooks)
useEffect(() => { refreshBooks() }, [])
@@ -26,81 +44,131 @@ export default function Library() {
const activeFormats = useMemo(() =>
ALL_FORMATS.filter(f => books.some(b => b.formats.includes(f))), [books])
const filtered = useMemo(() => filterBooks(books, query, genres, formats), [books, query, genres, formats])
const filtered = useMemo(
() => filterBooks(books, query, genres, formats),
[books, query, genres, formats])
const toggleGenre = (g: string) => setGenres(p => p.includes(g) ? p.filter(x => x !== g) : [...p, g])
const toggleFormat = (f: Format) => setFormats(p => p.includes(f) ? p.filter(x => x !== f) : [...p, f])
const sorted = useMemo(() => sortBooks(filtered, sortBy), [filtered, sortBy])
const toggleGenre = (g: string) => setGenres(p => p.includes(g) ? p.filter(x => x !== g) : [...p, g])
const toggleFormat = (f: Format) => setFormats(p => p.includes(f) ? p.filter(x => x !== f) : [...p, f])
const hasFilter = genres.length > 0 || formats.length > 0 || Boolean(query)
return (
<div className={s.layout}>
<div className={s.main}>
<div className={s.searchWrap}>
<div className={s.search}>
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
<input
className={s.searchInput}
type="search"
placeholder="Search books and authors…"
value={query}
onChange={e => setQuery(e.target.value)}
<div className={s.main}>
{/* ── Top bar ── */}
<Flex gap={12} align="center" className={s.topBar}>
<Input
prefix={<span style={{ color: 'rgba(0,0,0,.45)' }}></span>}
placeholder="Search books and authors…"
value={query}
onChange={e => setQuery(e.target.value)}
allowClear
style={{ flex: 1, maxWidth: 520 }}
/>
<Space style={{ marginLeft: 'auto' }} size={8}>
<Select
value={sortBy}
onChange={v => setSortBy(v)}
style={{ width: 110 }}
options={[
{ value: 'title', label: 'Title' },
{ value: 'author', label: 'Author' },
{ value: 'year', label: 'Year' },
]}
/>
</div>
</div>
<Segmented
value={viewMode}
onChange={v => setViewMode(v as ViewMode)}
options={[
{ value: 'grid', icon: <AppstoreOutlined /> },
{ value: 'list', icon: <UnorderedListOutlined /> },
]}
/>
</Space>
</Flex>
<div className={s.chips}>
{allGenres.map(g => (
<button
key={g}
className={`${s.chip} ${genres.includes(g) ? s.chipActive : ''}`}
onClick={() => toggleGenre(g)}
>
{genres.includes(g) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>}
{g}
</button>
))}
{activeFormats.length > 0 && <span className={s.divider} />}
{activeFormats.map(f => (
<button
key={f}
className={`${s.chip} ${formats.includes(f) ? s.chipActive : ''}`}
onClick={() => toggleFormat(f)}
>
{formats.includes(f) && <span className="material-symbols-outlined" style={{fontSize:16}}>done</span>}
{f.toUpperCase()}
</button>
))}
</div>
{/* ── Filter chips ── */}
{(allGenres.length > 0 || activeFormats.length > 0) && (
<Flex gap={6} wrap="wrap" align="center" className={s.chips}>
{allGenres.map(g => (
<Tag.CheckableTag
key={g}
checked={genres.includes(g)}
onChange={() => toggleGenre(g)}
>
{g}
</Tag.CheckableTag>
))}
{activeFormats.length > 0 && allGenres.length > 0 && (
<Divider type="vertical" style={{ height: 18, margin: 'auto 2px', borderColor: '#d9d9d9' }} />
)}
{activeFormats.map(f => (
<Tag.CheckableTag
key={f}
checked={formats.includes(f)}
onChange={() => toggleFormat(f)}
>
{f.toUpperCase()}
</Tag.CheckableTag>
))}
</Flex>
)}
<div className={s.countBar}>
<span className={s.count}>{filtered.length} of {books.length} books</span>
{/* ── Count / clear bar ── */}
<Flex align="center" gap={8} className={s.countBar}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{sorted.length} of {books.length} books
</Typography.Text>
{hasFilter && (
<button className={s.clearBtn} onClick={() => { setGenres([]); setFormats([]); setQuery('') }}>
<Button
type="link"
size="small"
icon={<CloseOutlined />}
style={{ padding: 0, height: 'auto' }}
onClick={() => { setGenres([]); setFormats([]); setQuery('') }}
>
Clear filters
</button>
</Button>
)}
</div>
</Flex>
<div className={s.grid}>
{filtered.map(book => (
<BookCard
key={book.id}
book={book}
selected={selected?.id === book.id}
onClick={b => setSelected(prev => prev?.id === b.id ? null : b)}
/>
))}
{filtered.length === 0 && (
<p className={s.empty}>No books match your filters.</p>
)}
</div>
</div>
<DetailPanel book={selected} onClose={() => setSelected(null)} />
<button className={s.fab} onClick={() => setAddOpen(true)} aria-label="Add book">
<span className="material-symbols-outlined">add</span>
</button>
{/* ── Book grid / list ── */}
{viewMode === 'grid' ? (
<div className={s.grid}>
{sorted.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
{sorted.length === 0 && (
<p className={s.empty}>No books match your filters.</p>
)}
</div>
) : (
<div className={s.list}>
{sorted.map(book => (
<BookRow
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
{sorted.length === 0 && (
<p className={s.empty}>No books match your filters.</p>
)}
</div>
)}
<FloatButton
icon={<PlusOutlined />}
type="primary"
onClick={() => setAddOpen(true)}
tooltip="Add book"
style={{ bottom: 24, right: 24 }}
/>
{addOpen && (
<AddBookDialog
@@ -4,95 +4,51 @@
overflow: hidden;
}
/* MD3 Navigation Drawer panel (permanent) used as book list */
.list {
width: 272px;
min-width: 272px;
border-right: 1px solid var(--md-sys-color-outline-variant);
width: 260px;
min-width: 260px;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--md-sys-color-surface-container-low);
background: #fafafa;
}
.listHeader {
padding: 12px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
padding: 10px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
/* MD3 Search field (smaller variant) */
.search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
background: var(--md-sys-color-surface-container-high);
border-radius: var(--md-sys-shape-full);
}
.searchIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 20px !important;
flex-shrink: 0;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface);
}
.searchInput::placeholder {
color: var(--md-sys-color-on-surface-variant);
}
.bookList {
flex: 1;
overflow-y: auto;
padding: 8px 4px;
padding: 6px 4px;
list-style: none;
}
/* MD3 List Item */
.bookItem {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: var(--md-sys-shape-full);
gap: 10px;
padding: 6px 10px;
border-radius: 20px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: background 200ms;
transition: background 150ms;
}
.bookItem::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
.bookItem:hover {
background: rgba(0, 0, 0, 0.04);
}
.bookItem:hover::before { opacity: .08; }
.bookItem:active::before { opacity: .12; }
.bookItemActive {
background: var(--md-sys-color-secondary-container);
}
.bookItemActive::before {
background: var(--md-sys-color-on-secondary-container);
background: #EDE7F6;
}
.thumb {
width: 32px;
height: 44px;
border-radius: var(--md-sys-shape-xs);
width: 30px;
height: 42px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
@@ -107,50 +63,28 @@
min-width: 0;
}
.bookTitle {
display: block;
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookItemActive .bookTitle {
color: var(--md-sys-color-on-secondary-container);
font-weight: 500;
}
.bookAuthor {
display: block;
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Editor pane */
.editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
min-width: 0;
}
.editorHeader {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
gap: 14px;
padding: 14px 24px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.editorCover {
width: 44px;
height: 60px;
border-radius: var(--md-sys-shape-xs);
width: 42px;
height: 58px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
@@ -160,22 +94,37 @@
flex-shrink: 0;
}
.editorTitle {
font: var(--md-sys-typescale-title-large);
color: var(--md-sys-color-on-surface);
}
.editorAuthor {
font: var(--md-sys-typescale-body-medium);
color: var(--md-sys-color-on-surface-variant);
margin-top: 2px;
}
.empty {
.formWrap {
padding: 16px 24px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-large);
}
/* Editions */
.editions {
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.editionList {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.editionItem {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
}
.editionIcon {
font-size: 14px;
color: rgba(0,0,0,.45);
flex-shrink: 0;
align-self: center;
display: flex;
}
+131 -22
View File
@@ -1,18 +1,29 @@
import { useEffect, useMemo, useState } from 'react'
import type { Book } from '../../types'
import { fetchBooks, updateBook } from '../../api/books'
import { useSearchParams } from 'react-router-dom'
import { Flex, Input, Typography } from 'antd'
import {
AudioOutlined,
ReadOutlined,
TabletOutlined,
} from '@ant-design/icons'
import type { Book, Edition, ReadingFormat } from '../../types'
import { fetchBooks, updateBook, fetchMetadataFromHardcover } from '../../api/books'
import MetadataForm from '../../components/MetadataForm/MetadataForm'
import s from './Metadata.module.css'
export default function Metadata() {
const [books, setBooks] = useState<Book[]>([])
const [books, setBooks] = useState<Book[]>([])
const [selected, setSelected] = useState<Book | null>(null)
const [query, setQuery] = useState('')
const [query, setQuery] = useState('')
const [fetchKey, setFetchKey] = useState(0)
const [searchParams] = useSearchParams()
useEffect(() => {
fetchBooks().then(list => {
setBooks(list)
if (list.length > 0) setSelected(list[0])
const bookId = searchParams.get('bookId')
const target = bookId ? list.find(b => b.id === Number(bookId)) : null
setSelected(target ?? (list.length > 0 ? list[0] : null))
})
}, [])
@@ -31,20 +42,26 @@ export default function Metadata() {
})
}
async function handleFetchMetadata() {
if (!selected) return
const updated = await fetchMetadataFromHardcover(selected.id)
setBooks(bs => bs.map(b => b.id === updated.id ? updated : b))
setSelected(updated)
setFetchKey(k => k + 1)
}
return (
<div className={s.layout}>
{/* Book list sidebar */}
<aside className={s.list}>
<div className={s.listHeader}>
<div className={s.search}>
<span className={`material-symbols-outlined ${s.searchIcon}`}>search</span>
<input
className={s.searchInput}
type="search"
placeholder="Filter books…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
<Input
placeholder="Filter books…"
value={query}
onChange={e => setQuery(e.target.value)}
allowClear
size="small"
/>
</div>
<ul className={s.bookList}>
@@ -58,14 +75,31 @@ export default function Metadata() {
{book.title[0]}
</div>
<div className={s.bookMeta}>
<span className={s.bookTitle}>{book.title}</span>
<span className={s.bookAuthor}>{book.authors.map(a => a.name).join(', ')}</span>
<Typography.Text
ellipsis
style={{
display: 'block',
fontSize: 13,
fontWeight: selected?.id === book.id ? 500 : 400,
color: selected?.id === book.id ? '#21005D' : 'rgba(0,0,0,.85)',
}}
>
{book.title}
</Typography.Text>
<Typography.Text
type="secondary"
ellipsis
style={{ display: 'block', fontSize: 11 }}
>
{book.authors.map(a => a.name).join(', ')}
</Typography.Text>
</div>
</li>
))}
</ul>
</aside>
{/* Editor pane */}
<main className={s.editor}>
{selected ? (
<>
@@ -73,17 +107,92 @@ export default function Metadata() {
<div className={s.editorCover} style={{ background: selected.color }}>
{selected.title[0]}
</div>
<div>
<p className={s.editorTitle}>{selected.title}</p>
<p className={s.editorAuthor}>{selected.authors.map(a => a.name).join(', ')}</p>
<div style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }} ellipsis>
{selected.title}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{selected.authors.map(a => a.name).join(', ')}
</Typography.Text>
</div>
</div>
<MetadataForm key={selected.id} book={selected} onSave={handleSave} />
<div className={s.formWrap}>
<MetadataForm
key={`${selected.id}-${fetchKey}`}
book={selected}
onSave={handleSave}
onFetchMetadata={selected.hardcoverId != null ? handleFetchMetadata : undefined}
/>
</div>
{selected.editions.length > 0 && (
<EditionsList editions={selected.editions} />
)}
</>
) : (
<div className={s.empty}>Select a book to edit metadata</div>
<Flex align="center" justify="center" style={{ flex: 1, height: '100%' }}>
<Typography.Text type="secondary">Select a book to edit metadata</Typography.Text>
</Flex>
)}
</main>
</div>
)
}
const FORMAT_ICON: Record<ReadingFormat, React.ReactNode> = {
Physical: <ReadOutlined />,
Audio: <AudioOutlined />,
Both: <ReadOutlined />,
Ebook: <TabletOutlined />,
}
function formatAudio(seconds: number) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function EditionsList({ editions }: { editions: Edition[] }) {
return (
<div className={s.editions}>
<Typography.Text
type="secondary"
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.06em' }}
>
Editions from Hardcover ({editions.length})
</Typography.Text>
<ul className={s.editionList}>
{editions.map(ed => {
const icon = ed.readingFormat ? FORMAT_ICON[ed.readingFormat] : <ReadOutlined />
const label = ed.editionFormat ?? ed.readingFormat ?? '—'
const meta: string[] = []
if (ed.publisher) meta.push(ed.publisher)
if (ed.releaseYear) meta.push(String(ed.releaseYear))
if (ed.pages) meta.push(`${ed.pages} pp`)
if (ed.audioSeconds) meta.push(formatAudio(ed.audioSeconds))
if (ed.language && ed.language !== 'English') meta.push(ed.language)
return (
<li key={ed.id} className={s.editionItem}>
<span className={s.editionIcon}>{icon}</span>
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>{label}</Typography.Text>
{(ed.isbn || ed.asin) && (
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace' }}
>
{ed.isbn ?? ed.asin}
</Typography.Text>
)}
{meta.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{meta.join(' · ')}
</Typography.Text>
)}
</li>
)
})}
</ul>
</div>
)
}
+56 -5
View File
@@ -1,12 +1,16 @@
export type Format = 'epub' | 'mobi' | 'pdf' | 'cbz' | 'cbr'
export type SeriesType = 'single-author' | 'multi-author'
export type AuthorRole = 'author' | 'editor'
export type QueueStatus = 'queued' | 'downloading' | 'completed' | 'failed'
export type SourceType = 'folder' | 'calibre' | 'opds' | 'url'
export interface Author {
id: number
name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
slug: string | null
role: string
}
export interface Series {
@@ -22,10 +26,22 @@ export interface SeriesEntry {
arc?: string
}
export interface BookAuthor {
bookId: string
authorId: string
role: AuthorRole
export type ReadingFormat = 'Physical' | 'Audio' | 'Both' | 'Ebook'
export interface Edition {
id: number
isbn: string | null
asin: string | null
publisher: string | null
releaseYear: number | null
readingFormat: ReadingFormat | null
/** Detailed format from Hardcover, e.g. "Hardcover", "Mass Market Paperback" */
editionFormat: string | null
pages: number | null
audioSeconds: number | null
language: string | null
languageCode: string | null
coverUrl: string | null
}
export interface Book {
@@ -44,6 +60,7 @@ export interface Book {
coverUrl: string | null
isbn: string | null
hardcoverId: number | null
editions: Edition[]
}
export interface QueueItem {
@@ -64,6 +81,25 @@ export interface HardcoverSearchResult {
genres: string[]
}
export interface AuthorSummary {
id: number
name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
bookCount: number
}
export interface AuthorDetail {
id: number
name: string
bio: string | null
bornYear: number | null
imageUrl: string | null
slug: string | null
books: Book[]
}
export interface ImportSource {
id: string
name: string
@@ -71,3 +107,18 @@ export interface ImportSource {
path: string
enabled: boolean
}
export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac'
export interface BookFile {
id: number
bookId: number | null
editionId: number | null
sourceId: string | null
path: string
filename: string
sizeBytes: number
format: FileFormat
hash: string | null
addedAt: string
}
+22
View File
@@ -1 +1,23 @@
import '@testing-library/jest-dom'
// Ant Design uses ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
// Ant Design uses matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})