Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -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>
|
||||
|
||||
Generated
+956
-2
File diff suppressed because it is too large
Load Diff
@@ -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 +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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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`, {})
|
||||
}
|
||||
|
||||
@@ -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,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 */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user