Project scaffolding

This commit is contained in:
2026-03-15 22:31:31 +02:00
parent 501528897d
commit cbd7f52535
106 changed files with 11222 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# In development, leave VITE_API_URL unset — Vite proxies /api to http://localhost:5278
# VITE_API_URL=http://localhost:5278/api
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
.env.*.local
coverage/
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS final
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy /api to the backend
location /api/ {
proxy_pass http://pagemanager.api:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback — all other routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+3844
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pagemanager-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"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",
"@vitest/coverage-v8": "^3.1.1",
"jsdom": "^26.1.0",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vitest": "^3.1.1"
}
}
+20
View File
@@ -0,0 +1,20 @@
.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;
}
+22
View File
@@ -0,0 +1,22 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import Sidebar from './components/Sidebar/Sidebar'
import Library from './pages/Library/Library'
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>
)
}
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchBooks, fetchBook, updateBook } from '../books'
const mockBook = {
id: 1,
title: 'Dune',
year: 1965,
publisher: null,
pages: null,
description: null,
formats: ['epub'] as const,
color: '#6366f1',
genres: ['Science Fiction'],
authors: [{ id: 1, name: 'Frank Herbert' }],
coverUrl: null,
isbn: null,
hardcoverId: null,
}
function mockFetch(body: unknown, ok = true, status = 200) {
return vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
json: () => Promise.resolve(body),
})
}
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch([mockBook]))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('fetchBooks', () => {
it('calls GET /api/books', async () => {
await fetchBooks()
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/books'),
expect.objectContaining({ headers: expect.any(Object) }),
)
})
it('returns the parsed array', async () => {
const result = await fetchBooks()
expect(result).toEqual([mockBook])
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', mockFetch(null, false, 500))
await expect(fetchBooks()).rejects.toThrow('500')
})
})
describe('fetchBook', () => {
it('calls GET /api/books/:id', async () => {
vi.stubGlobal('fetch', mockFetch(mockBook))
await fetchBook(42)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/books/42'),
expect.any(Object),
)
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', mockFetch(null, false, 404))
await expect(fetchBook(99)).rejects.toThrow('404')
})
})
describe('updateBook', () => {
it('calls PUT /api/books/:id with JSON body', async () => {
vi.stubGlobal('fetch', mockFetch(mockBook))
const patch = { title: 'New Title' }
await updateBook(1, patch)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/books/1'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify(patch),
}),
)
})
it('returns the updated book', async () => {
vi.stubGlobal('fetch', mockFetch(mockBook))
const result = await updateBook(1, { title: 'New Title' })
expect(result).toEqual(mockBook)
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', mockFetch(null, false, 404))
await expect(updateBook(99, {})).rejects.toThrow('404')
})
})
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../importQueue'
import type { QueueItem, ImportSource } from '../../types'
const mockQueue: QueueItem[] = [
{ id: 'q1', filename: 'book.epub', sizeBytes: 1024, downloadedBytes: 512, status: 'downloading', source: 'https://example.com' },
]
const mockSources: ImportSource[] = [
{ id: 's1', name: 'My Library', type: 'folder', path: '/books', enabled: true },
]
function mockFetch(body: unknown, ok = true, status = 200) {
return vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
json: () => Promise.resolve(body),
})
}
afterEach(() => vi.unstubAllGlobals())
describe('fetchQueue', () => {
it('calls GET /api/queue', async () => {
vi.stubGlobal('fetch', mockFetch(mockQueue))
await fetchQueue()
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/queue'), expect.any(Object))
})
it('returns queue items', async () => {
vi.stubGlobal('fetch', mockFetch(mockQueue))
expect(await fetchQueue()).toEqual(mockQueue)
})
})
describe('fetchSources', () => {
it('calls GET /api/sources', async () => {
vi.stubGlobal('fetch', mockFetch(mockSources))
await fetchSources()
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/sources'), expect.any(Object))
})
it('returns sources', async () => {
vi.stubGlobal('fetch', mockFetch(mockSources))
expect(await fetchSources()).toEqual(mockSources)
})
})
describe('retryQueueItem', () => {
it('calls POST /api/queue/:id/retry', async () => {
vi.stubGlobal('fetch', mockFetch(null))
await retryQueueItem('q1')
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/queue/q1/retry'),
expect.objectContaining({ method: 'POST' }),
)
})
})
describe('removeQueueItem', () => {
it('calls DELETE /api/queue/:id', async () => {
vi.stubGlobal('fetch', mockFetch(null))
await removeQueueItem('q1')
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/queue/q1'),
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('updateSource', () => {
it('calls PATCH /api/sources/:id with enabled flag', async () => {
vi.stubGlobal('fetch', mockFetch({ ...mockSources[0], enabled: false }))
await updateSource('s1', false)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/sources/s1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ enabled: false }),
}),
)
})
})
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { searchHardcover, addBookFromHardcover } from '../search'
import type { HardcoverSearchResult, Book } from '../../types'
const mockResults: HardcoverSearchResult[] = [
{ id: 1, title: 'Dune', authors: ['Frank Herbert'], year: 1965, genres: ['Science Fiction'] },
]
const mockBook: Book = {
id: 10,
title: 'Dune',
year: 1965,
publisher: 'Ace Books',
pages: 412,
description: null,
formats: [],
color: '#6366f1',
genres: ['Science Fiction'],
authors: [{ id: 1, name: 'Frank Herbert' }],
coverUrl: null,
isbn: null,
hardcoverId: 1,
}
function mockFetch(body: unknown, ok = true, status = 200) {
return vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
json: () => Promise.resolve(body),
})
}
afterEach(() => vi.unstubAllGlobals())
describe('searchHardcover', () => {
it('calls GET /api/search/books with encoded query', async () => {
vi.stubGlobal('fetch', mockFetch(mockResults))
await searchHardcover('dune frank')
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/search/books?q=dune%20frank'),
expect.any(Object),
)
})
it('returns parsed results array', async () => {
vi.stubGlobal('fetch', mockFetch(mockResults))
const result = await searchHardcover('dune')
expect(result).toEqual(mockResults)
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', mockFetch(null, false, 500))
await expect(searchHardcover('dune')).rejects.toThrow('500')
})
})
describe('addBookFromHardcover', () => {
it('calls POST /api/books with hardcoverId in body', async () => {
vi.stubGlobal('fetch', mockFetch(mockBook))
await addBookFromHardcover(42)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/books'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ hardcoverId: 42 }),
}),
)
})
it('returns the created book', async () => {
vi.stubGlobal('fetch', mockFetch(mockBook))
const result = await addBookFromHardcover(1)
expect(result).toEqual(mockBook)
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', mockFetch(null, false, 404))
await expect(addBookFromHardcover(999)).rejects.toThrow('404')
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { Book } from '../types'
import { api } from './client'
export function fetchBooks(): Promise<Book[]> {
return api.get<Book[]>('/books')
}
export function fetchBook(id: number): Promise<Book> {
return api.get<Book>(`/books/${id}`)
}
export function updateBook(id: number, patch: Partial<Book>): Promise<Book> {
return api.put<Book>(`/books/${id}`, patch)
}
+21
View File
@@ -0,0 +1,21 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
+22
View File
@@ -0,0 +1,22 @@
import type { QueueItem, ImportSource } from '../types'
import { api } from './client'
export function fetchQueue(): Promise<QueueItem[]> {
return api.get<QueueItem[]>('/queue')
}
export function fetchSources(): Promise<ImportSource[]> {
return api.get<ImportSource[]>('/sources')
}
export function retryQueueItem(id: string): Promise<void> {
return api.post<void>(`/queue/${id}/retry`, {})
}
export function removeQueueItem(id: string): Promise<void> {
return api.delete(`/queue/${id}`)
}
export function updateSource(id: string, enabled: boolean): Promise<ImportSource> {
return api.patch<ImportSource>(`/sources/${id}`, { enabled })
}
+10
View File
@@ -0,0 +1,10 @@
import type { Book, HardcoverSearchResult } from '../types'
import { api } from './client'
export function searchHardcover(q: string): Promise<HardcoverSearchResult[]> {
return api.get<HardcoverSearchResult[]>(`/search/books?q=${encodeURIComponent(q)}`)
}
export function addBookFromHardcover(hardcoverId: number): Promise<Book> {
return api.post<Book>('/books', { hardcoverId })
}
@@ -0,0 +1,194 @@
/* 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;
}
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react'
import type { Book, HardcoverSearchResult } from '../../types'
import { searchHardcover, addBookFromHardcover } from '../../api/search'
import s from './AddBookDialog.module.css'
interface Props {
onClose: () => void
onAdded: (book: Book) => void
}
export default function AddBookDialog({ onClose, onAdded }: Props) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<HardcoverSearchResult[]>([])
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)
useEffect(() => { inputRef.current?.focus() }, [])
// Debounced search
useEffect(() => {
if (!query.trim()) { setResults([]); return }
const timer = setTimeout(() => {
setLoading(true)
searchHardcover(query)
.then(setResults)
.catch(() => setResults([]))
.finally(() => setLoading(false))
}, 400)
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)
try {
const book = await addBookFromHardcover(result.id)
setAdded(prev => new Set(prev).add(result.id))
onAdded(book)
} finally {
setAdding(null)
}
}
const showEmpty = !loading && query.trim() && results.length === 0
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>
<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>
</div>
<div className={s.results}>
{loading && (
<div className={s.spinner}>
<span className="material-symbols-outlined">progress_activity</span>
</div>
)}
{showHint && (
<p className={s.empty}>Start typing to search Hardcover</p>
)}
{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
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>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,119 @@
/* 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);
overflow: hidden;
cursor: pointer;
position: relative;
transition: box-shadow 200ms cubic-bezier(.2,0,0,1);
outline: none;
}
.card:hover {
box-shadow: var(--md-sys-elevation-2);
}
.card.selected {
box-shadow: var(--md-sys-elevation-2);
outline: 2px solid var(--md-sys-color-primary);
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);
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); }
.cover {
aspect-ratio: 2/3;
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: 1.75rem;
font-weight: 500;
color: rgba(255,255,255,.3);
letter-spacing: .04em;
user-select: none;
}
.seriesPill {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,.45);
color: #fff;
font: var(--md-sys-typescale-label-small);
padding: 2px 6px;
border-radius: var(--md-sys-shape-xs);
}
.body {
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.title {
font: var(--md-sys-typescale-title-small);
color: var(--md-sys-color-on-surface);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.author {
font: var(--md-sys-typescale-body-small);
color: var(--md-sys-color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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;
}
@@ -0,0 +1,47 @@
import type { Book } from '../../types'
import s from './BookCard.module.css'
interface Props {
book: Book
onClick: (book: Book) => void
selected?: boolean
}
export default function BookCard({ book, onClick, selected }: Props) {
const initials = book.title
.split(' ')
.slice(0, 2)
.map(w => w[0])
.join('')
.toUpperCase()
return (
<article
className={`${s.card} ${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>
}
{book.series && (
<span className={s.seriesPill}>#{book.series.position}</span>
)}
</div>
<div className={s.body}>
<p className={s.title}>{book.title}</p>
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
<div className={s.chips}>
{book.formats.map(f => (
<span key={f} className={s.chip}>{f.toUpperCase()}</span>
))}
</div>
</div>
{/* MD3 state layer */}
<div className={s.stateLayer} />
</article>
)
}
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import BookCard from '../BookCard'
import type { Book } from '../../../types'
function makeBook(overrides: Partial<Book> = {}): Book {
return {
id: 1,
title: 'Dune',
year: 1965,
publisher: null,
pages: null,
description: null,
formats: ['epub'],
color: '#6366f1',
genres: ['Science Fiction'],
authors: [{ id: 1, name: 'Frank Herbert' }],
coverUrl: null,
isbn: null,
hardcoverId: null,
...overrides,
}
}
describe('BookCard', () => {
it('renders the title', () => {
render(<BookCard book={makeBook()} onClick={vi.fn()} />)
expect(screen.getByText('Dune')).toBeInTheDocument()
})
it('renders the author name', () => {
render(<BookCard book={makeBook()} onClick={vi.fn()} />)
expect(screen.getByText('Frank Herbert')).toBeInTheDocument()
})
it('renders format chips', () => {
render(<BookCard book={makeBook({ formats: ['epub', 'mobi'] })} onClick={vi.fn()} />)
expect(screen.getByText('EPUB')).toBeInTheDocument()
expect(screen.getByText('MOBI')).toBeInTheDocument()
})
it('renders series position pill when series exists', () => {
render(<BookCard book={makeBook({ series: { name: 'Dune Chronicles', position: 1 } })} onClick={vi.fn()} />)
expect(screen.getByText('#1')).toBeInTheDocument()
})
it('does not render series position pill when series is absent', () => {
render(<BookCard book={makeBook({ series: undefined })} onClick={vi.fn()} />)
expect(screen.queryByText(/#\d/)).not.toBeInTheDocument()
})
it('calls onClick with the book when clicked', async () => {
const user = userEvent.setup()
const book = makeBook()
const onClick = vi.fn()
render(<BookCard book={book} onClick={onClick} />)
await user.click(screen.getByRole('article'))
expect(onClick).toHaveBeenCalledWith(book)
})
it('applies selected styling when selected is true', () => {
const { container } = render(<BookCard book={makeBook()} onClick={vi.fn()} selected={true} />)
const article = container.querySelector('article')
expect(article?.className).toContain('selected')
})
it('does not apply selected styling when selected is false', () => {
const { container } = render(<BookCard book={makeBook()} onClick={vi.fn()} selected={false} />)
const article = container.querySelector('article')
expect(article?.className).not.toContain('selected')
})
})
@@ -0,0 +1,259 @@
/* 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; }
@@ -0,0 +1,97 @@
import type { Book } from '../../types'
import s from './DetailPanel.module.css'
interface Props {
book: Book | null
onClose: () => void
}
export default function DetailPanel({ book, onClose }: 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>
{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>
)}
{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>
</>
)
}
function Stat({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className={s.stat}>
<span className={`material-symbols-outlined ${s.statIcon}`}>{icon}</span>
<div>
<p className={s.statLabel}>{label}</p>
<p className={s.statValue}>{value}</p>
</div>
</div>
)
}
@@ -0,0 +1,224 @@
.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);
}
@@ -0,0 +1,137 @@
import { useEffect, useId, useState } from 'react'
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
}
export default function MetadataForm({ book, onSave }: Props) {
const [form, setForm] = useState<FormState>(() => toForm(book))
const [saved, setSaved] = useState(false)
useEffect(() => {
setForm(toForm(book))
setSaved(false)
}, [book.id])
const set = (field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [field]: e.target.value }))
function handleSave(e: React.FormEvent) {
e.preventDefault()
onSave({
title: form.title,
publisher: form.publisher || null,
year: form.year ? Number(form.year) : null,
pages: form.pages ? Number(form.pages) : null,
genres: form.genres.split(',').map(g => g.trim()).filter(Boolean),
description: form.description || null,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
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')} />
</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>
</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>
)
}
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MetadataForm from '../MetadataForm'
import type { Book } from '../../../types'
function makeBook(overrides: Partial<Book> = {}): Book {
return {
id: 1,
title: 'Dune',
year: 1965,
publisher: 'Chilton Books',
pages: 412,
description: 'A sci-fi classic.',
formats: ['epub'],
color: '#6366f1',
genres: ['Science Fiction'],
authors: [{ id: 1, name: 'Frank Herbert' }],
coverUrl: null,
isbn: null,
hardcoverId: null,
...overrides,
}
}
describe('MetadataForm', () => {
it('renders the title field with book title', () => {
render(<MetadataForm book={makeBook()} onSave={vi.fn()} />)
expect(screen.getByDisplayValue('Dune')).toBeInTheDocument()
})
it('renders the author field joined by comma', () => {
render(<MetadataForm book={makeBook({ authors: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] })} onSave={vi.fn()} />)
expect(screen.getByDisplayValue('Alice, Bob')).toBeInTheDocument()
})
it('calls onSave with patch when form is submitted', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(<MetadataForm book={makeBook()} onSave={onSave} />)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledOnce()
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune' }))
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { toForm } from '../utils'
import type { Book } from '../../../types'
function makeBook(overrides: Partial<Book> = {}): Book {
return {
id: 1,
title: 'Default Title',
year: null,
publisher: null,
pages: null,
description: null,
formats: [],
color: '#6366f1',
genres: [],
authors: [],
series: undefined,
coverUrl: null,
isbn: null,
hardcoverId: null,
...overrides,
}
}
describe('toForm', () => {
it('maps title directly', () => {
const result = toForm(makeBook({ title: 'Dune' }))
expect(result.title).toBe('Dune')
})
it('joins multiple authors with ", "', () => {
const result = toForm(makeBook({
authors: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
}))
expect(result.authors).toBe('Alice, Bob')
})
it('maps nullable year to empty string when absent', () => {
expect(toForm(makeBook({ year: null })).year).toBe('')
})
it('maps year to string when present', () => {
expect(toForm(makeBook({ year: 2001 })).year).toBe('2001')
})
it('maps nullable publisher to empty string', () => {
expect(toForm(makeBook({ publisher: null })).publisher).toBe('')
})
it('maps nullable pages to empty string', () => {
expect(toForm(makeBook({ pages: null })).pages).toBe('')
})
it('maps pages to string when present', () => {
expect(toForm(makeBook({ pages: 412 })).pages).toBe('412')
})
it('maps nullable description to empty string', () => {
expect(toForm(makeBook({ description: null })).description).toBe('')
})
it('joins genres with ", "', () => {
const result = toForm(makeBook({ genres: ['Fantasy', 'Adventure'] }))
expect(result.genres).toBe('Fantasy, Adventure')
})
it('maps series name and position when present', () => {
const result = toForm(makeBook({ series: { name: 'Mistborn', position: 1, arc: undefined } }))
expect(result.series).toBe('Mistborn')
expect(result.seriesPosition).toBe('1')
})
it('maps series fields to empty strings when series absent', () => {
const result = toForm(makeBook({ series: undefined }))
expect(result.series).toBe('')
expect(result.seriesPosition).toBe('')
})
})
@@ -0,0 +1,27 @@
import type { Book } from '../../types'
export interface FormState {
title: string
authors: string
series: string
seriesPosition: string
publisher: string
year: string
genres: string
pages: string
description: string
}
export function toForm(book: Book): FormState {
return {
title: book.title,
authors: book.authors.map(a => a.name).join(', '),
series: book.series?.name ?? '',
seriesPosition: book.series ? String(book.series.position) : '',
publisher: book.publisher ?? '',
year: book.year ? String(book.year) : '',
genres: book.genres.join(', '),
pages: book.pages ? String(book.pages) : '',
description: book.description ?? '',
}
}
@@ -0,0 +1,104 @@
/* 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; }
@@ -0,0 +1,60 @@
import type { QueueItem as IQueueItem } from '../../types'
import { formatBytes } from './utils'
import s from './QueueItem.module.css'
interface Props {
item: IQueueItem
onRetry: (id: string) => void
onRemove: (id: string) => void
}
const STATUS_ICON: Record<IQueueItem['status'], string> = {
queued: 'schedule',
downloading: 'downloading',
completed: 'check_circle',
failed: 'error',
}
export default function QueueItem({ item, onRetry, onRemove }: Props) {
const pct = item.sizeBytes > 0
? Math.round((item.downloadedBytes / item.sizeBytes) * 100)
: 0
return (
<div className={`${s.item} ${s[`status_${item.status}`]}`}>
<span className={`material-symbols-outlined ${s.statusIcon} ${s[`icon_${item.status}`]}`}>
{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>
<span className={s.source}>{item.source}</span>
{(item.status === 'downloading' || item.status === 'queued') && (
<div className={s.progressTrack}>
<div className={s.progressBar} style={{ width: `${pct}%` }} />
</div>
)}
{item.status === 'failed' && item.error && (
<p className={s.error}>{item.error}</p>
)}
<div className={s.actions}>
{item.status === 'failed' && (
<button className={s.textBtn} onClick={() => onRetry(item.id)}>Retry</button>
)}
<button className={s.textBtn} onClick={() => onRemove(item.id)}>Remove</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import QueueItem from '../QueueItem'
import type { QueueItem as IQueueItem } from '../../../types'
function makeItem(overrides: Partial<IQueueItem> = {}): IQueueItem {
return {
id: 'item-1',
filename: 'book.epub',
sizeBytes: 1024 * 1024,
downloadedBytes: 0,
status: 'queued',
source: 'https://example.com',
...overrides,
}
}
describe('QueueItem', () => {
it('renders the filename', () => {
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={vi.fn()} />)
expect(screen.getByText('book.epub')).toBeInTheDocument()
})
it('renders the source', () => {
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={vi.fn()} />)
expect(screen.getByText('https://example.com')).toBeInTheDocument()
})
it('calls onRemove with item id when Remove is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
render(<QueueItem item={makeItem()} onRetry={vi.fn()} onRemove={onRemove} />)
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onRemove).toHaveBeenCalledWith('item-1')
})
it('shows Retry button for failed items', () => {
render(<QueueItem item={makeItem({ status: 'failed' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('calls onRetry with item id when Retry is clicked', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
render(<QueueItem item={makeItem({ status: 'failed' })} onRetry={onRetry} onRemove={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledWith('item-1')
})
it('does not show Retry button for non-failed items', () => {
render(<QueueItem item={makeItem({ status: 'completed' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
})
it('renders error message when status is failed and error is set', () => {
render(<QueueItem item={makeItem({ status: 'failed', error: 'Connection refused' })} onRetry={vi.fn()} onRemove={vi.fn()} />)
expect(screen.getByText('Connection refused')).toBeInTheDocument()
})
})
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { formatBytes } from '../utils'
describe('formatBytes', () => {
it('returns bytes for values under 1 KB', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(1023)).toBe('1023 B')
})
it('returns KB for values between 1 KB and 1 MB', () => {
expect(formatBytes(1024)).toBe('1 KB')
expect(formatBytes(2048)).toBe('2 KB')
expect(formatBytes(1024 * 1024 - 1)).toBe('1024 KB')
})
it('returns MB with one decimal for values over 1 MB', () => {
expect(formatBytes(1024 * 1024)).toBe('1.0 MB')
expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB')
expect(formatBytes(10 * 1024 * 1024)).toBe('10.0 MB')
})
})
@@ -0,0 +1,5 @@
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
@@ -0,0 +1,152 @@
.rail {
width: var(--nav-rail-w);
min-width: var(--nav-rail-w);
height: 100%;
background: var(--md-sys-color-surface-container-low);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0 16px;
}
.brand {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
}
.brandIcon {
color: var(--md-sys-color-on-surface-variant);
font-size: 28px !important;
}
.nav {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
width: 100%;
padding: 0 8px;
}
.link {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 0 8px;
text-decoration: none;
position: relative;
}
.indicator {
position: relative;
width: 56px;
height: 32px;
border-radius: var(--md-sys-shape-full);
display: flex;
align-items: center;
justify-content: center;
transition: background 200ms cubic-bezier(.2,0,0,1);
}
/* Hover state layer */
.link:hover .indicator::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface);
opacity: .08;
}
.link.active .indicator {
background: var(--md-sys-color-secondary-container);
}
.link.active:hover .indicator::before {
background: var(--md-sys-color-on-secondary-container);
}
.icon {
color: var(--md-sys-color-on-surface-variant);
font-size: 24px !important;
transition: font-variation-settings 200ms;
}
.iconFilled {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
color: var(--md-sys-color-on-secondary-container);
}
.badge {
position: absolute;
top: -2px;
right: 6px;
background: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
font: var(--md-sys-typescale-label-small);
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.label {
font: var(--md-sys-typescale-label-medium);
color: var(--md-sys-color-on-surface-variant);
text-align: center;
}
.link.active .label {
color: var(--md-sys-color-on-surface);
font-weight: 700;
}
.footer {
width: 100%;
padding: 0 8px;
}
.footerBtn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 0 8px;
}
.footerBtn .indicator {
width: 56px;
height: 32px;
border-radius: var(--md-sys-shape-full);
display: flex;
align-items: center;
justify-content: center;
}
.footerBtn:hover .indicator::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface);
opacity: .08;
}
.footerBtn .icon {
color: var(--md-sys-color-on-surface-variant);
}
.footerBtn .label {
font: var(--md-sys-typescale-label-medium);
color: var(--md-sys-color-on-surface-variant);
}
@@ -0,0 +1,62 @@
import { NavLink } from 'react-router-dom'
import s from './Sidebar.module.css'
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' },
]
export default function Sidebar() {
return (
<nav className={s.rail}>
<div className={s.brand}>
<span className={`material-symbols-outlined ${s.brandIcon}`}>auto_stories</span>
</div>
<ul className={s.nav}>
{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}
</span>
</div>
<span className={s.label}>{item.label}</span>
</>
)}
</NavLink>
</li>
))}
</ul>
<div className={s.footer}>
<button className={s.footerBtn}>
<div className={s.indicator}>
<span className="material-symbols-outlined">settings</span>
</div>
<span className={s.label}>Settings</span>
</button>
</div>
</nav>
)
}
View File
+101
View File
@@ -0,0 +1,101 @@
*, *::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);
border-radius: 2px;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
@@ -0,0 +1,213 @@
.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);
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from 'react'
import type { QueueItem as IQueueItem, ImportSource } from '../../types'
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue'
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',
}
export default function Import() {
const [queue, setQueue] = useState<IQueueItem[]>([])
const [sources, setSources] = useState<ImportSource[]>([])
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetchQueue().then(setQueue)
fetchSources().then(setSources)
}, [])
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
console.log('dropped files:', Array.from(e.dataTransfer.files).map(f => f.name))
}
function handleRetry(id: string) {
retryQueueItem(id).then(() =>
setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i))
)
}
function handleRemove(id: string) {
removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id)))
}
function toggleSource(id: string) {
const current = sources.find(s => s.id === id)
if (!current) return
const enabled = !current.enabled
setSources(s => s.map(src => src.id === id ? { ...src, enabled } : src))
updateSource(id, enabled).catch(() =>
setSources(s => s.map(src => src.id === id ? { ...src, enabled: !enabled } : src))
)
}
const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued')
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
return (
<div className={s.page}>
{/* Left column */}
<div className={s.col}>
<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()}
>
<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>
</section>
<section>
<h2 className={s.heading}>Sources</h2>
<ul className={s.sourceList}>
{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>
<div className={s.sourceInfo}>
<span className={s.sourceName}>{src.name}</span>
<span className={s.sourcePath}>{src.path}</span>
</div>
<button
className={`${s.switch} ${src.enabled ? s.switchOn : ''}`}
onClick={() => toggleSource(src.id)}
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
>
<span className={s.switchThumb} />
</button>
</li>
))}
</ul>
<button className={s.addBtn}>
<span className="material-symbols-outlined">add</span>
Add source
</button>
</section>
</div>
{/* Right column */}
<div className={s.col}>
{active.length > 0 && (
<section>
<h2 className={s.heading}>
Downloading
<span className={s.headingBadge}>{active.length}</span>
</h2>
<ul className={s.queueList}>
{active.map(item => (
<li key={item.id}>
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
))}
</ul>
</section>
)}
{finished.length > 0 && (
<section>
<h2 className={s.heading}>History</h2>
<ul className={s.queueList}>
{finished.map(item => (
<li key={item.id}>
<QueueItem item={item} onRetry={handleRetry} onRemove={handleRemove} />
</li>
))}
</ul>
</section>
)}
{queue.length === 0 && (
<div className={s.empty}>No recent activity.</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,185 @@
.layout {
display: flex;
height: 100%;
overflow: hidden;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* MD3 Search bar */
.searchWrap {
padding: 16px 16px 8px;
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;
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;
flex-shrink: 0;
}
.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;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
gap: 8px;
align-content: start;
}
.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);
}
/* 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; }
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react'
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 AddBookDialog from '../../components/AddBookDialog/AddBookDialog'
import s from './Library.module.css'
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)
const refreshBooks = () => fetchBooks().then(setBooks)
useEffect(() => { refreshBooks() }, [])
const allGenres = useMemo(() =>
[...new Set(books.flatMap(b => b.genres))].sort(), [books])
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 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>
</div>
<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>
<div className={s.countBar}>
<span className={s.count}>{filtered.length} of {books.length} books</span>
{hasFilter && (
<button className={s.clearBtn} onClick={() => { setGenres([]); setFormats([]); setQuery('') }}>
Clear filters
</button>
)}
</div>
<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>
{addOpen && (
<AddBookDialog
onClose={() => setAddOpen(false)}
onAdded={book => { setBooks(prev => [book, ...prev]); setAddOpen(false) }}
/>
)}
</div>
)
}
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { filterBooks } from '../utils'
import type { Book } from '../../../types'
function makeBook(overrides: Partial<Book> & { id: number; title: string }): Book {
return {
year: null,
publisher: null,
pages: null,
description: null,
formats: [],
color: '#6366f1',
genres: [],
authors: [],
coverUrl: null,
isbn: null,
hardcoverId: null,
...overrides,
}
}
const books: Book[] = [
makeBook({ id: 1, title: 'Dune', authors: [{ id: 1, name: 'Frank Herbert' }], genres: ['Science Fiction'], formats: ['epub'] }),
makeBook({ id: 2, title: 'Foundation', authors: [{ id: 2, name: 'Isaac Asimov' }], genres: ['Science Fiction', 'Classic'], formats: ['mobi', 'pdf'] }),
makeBook({ id: 3, title: 'The Hobbit', authors: [{ id: 3, name: 'J.R.R. Tolkien' }], genres: ['Fantasy'], formats: ['epub', 'mobi'] }),
]
describe('filterBooks', () => {
it('returns all books when no filters applied', () => {
expect(filterBooks(books, '', [], [])).toHaveLength(3)
})
it('filters by title case-insensitively', () => {
const result = filterBooks(books, 'dune', [], [])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('Dune')
})
it('filters by author name case-insensitively', () => {
const result = filterBooks(books, 'tolkien', [], [])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('The Hobbit')
})
it('filters by genre', () => {
const result = filterBooks(books, '', ['Fantasy'], [])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('The Hobbit')
})
it('filters by format', () => {
const result = filterBooks(books, '', [], ['pdf'])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('Foundation')
})
it('applies genre and format filters with AND logic', () => {
const result = filterBooks(books, '', ['Science Fiction'], ['mobi'])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('Foundation')
})
it('applies query and genre filter together', () => {
const result = filterBooks(books, 'foundation', ['Science Fiction'], [])
expect(result).toHaveLength(1)
expect(result[0].title).toBe('Foundation')
})
it('returns empty array when nothing matches', () => {
expect(filterBooks(books, 'nonexistent', [], [])).toHaveLength(0)
})
})
@@ -0,0 +1,19 @@
import type { Book, Format } from '../../types'
export function filterBooks(
books: Book[],
query: string,
genres: string[],
formats: Format[],
): Book[] {
return books.filter(b => {
if (
query &&
!b.title.toLowerCase().includes(query.toLowerCase()) &&
!b.authors.some(a => a.name.toLowerCase().includes(query.toLowerCase()))
) return false
if (genres.length && !genres.some(g => b.genres.includes(g))) return false
if (formats.length && !formats.some(f => b.formats.includes(f))) return false
return true
})
}
@@ -0,0 +1,181 @@
.layout {
display: flex;
height: 100%;
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);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--md-sys-color-surface-container-low);
}
.listHeader {
padding: 12px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
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;
}
/* MD3 List Item */
.bookItem {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: var(--md-sys-shape-full);
cursor: pointer;
position: relative;
overflow: hidden;
transition: background 200ms;
}
.bookItem::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 200ms;
}
.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);
}
.thumb {
width: 32px;
height: 44px;
border-radius: var(--md-sys-shape-xs);
display: flex;
align-items: center;
justify-content: center;
font-size: .875rem;
font-weight: 500;
color: rgba(255,255,255,.4);
flex-shrink: 0;
}
.bookMeta {
flex: 1;
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;
}
.editorHeader {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
flex-shrink: 0;
}
.editorCover {
width: 44px;
height: 60px;
border-radius: var(--md-sys-shape-xs);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 500;
color: rgba(255,255,255,.35);
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 {
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);
}
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useState } from 'react'
import type { Book } from '../../types'
import { fetchBooks, updateBook } 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 [selected, setSelected] = useState<Book | null>(null)
const [query, setQuery] = useState('')
useEffect(() => {
fetchBooks().then(list => {
setBooks(list)
if (list.length > 0) setSelected(list[0])
})
}, [])
const filtered = useMemo(() =>
books.filter(b =>
!query ||
b.title.toLowerCase().includes(query.toLowerCase()) ||
b.authors.some(a => a.name.toLowerCase().includes(query.toLowerCase()))
), [books, query])
function handleSave(patch: Partial<Book>) {
if (!selected) return
updateBook(selected.id, patch).then(updated => {
setBooks(bs => bs.map(b => b.id === updated.id ? updated : b))
setSelected(updated)
})
}
return (
<div className={s.layout}>
<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>
</div>
<ul className={s.bookList}>
{filtered.map(book => (
<li
key={book.id}
className={`${s.bookItem} ${selected?.id === book.id ? s.bookItemActive : ''}`}
onClick={() => setSelected(book)}
>
<div className={s.thumb} style={{ background: book.color }}>
{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>
</div>
</li>
))}
</ul>
</aside>
<main className={s.editor}>
{selected ? (
<>
<div className={s.editorHeader}>
<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>
</div>
<MetadataForm key={selected.id} book={selected} onSave={handleSave} />
</>
) : (
<div className={s.empty}>Select a book to edit metadata</div>
)}
</main>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
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
}
export interface Series {
id: string
name: string
type: SeriesType
}
export interface SeriesEntry {
seriesId: string
bookId: string
position: number
arc?: string
}
export interface BookAuthor {
bookId: string
authorId: string
role: AuthorRole
}
export interface Book {
id: number
title: string
year: number | null
publisher: string | null
pages: number | null
description: string | null
formats: Format[]
color: string
genres: string[]
authors: Author[]
series?: { name: string; position: number; arc?: string }
// Metadata source fields
coverUrl: string | null
isbn: string | null
hardcoverId: number | null
}
export interface QueueItem {
id: string
filename: string
sizeBytes: number
downloadedBytes: number
status: QueueStatus
source: string
error?: string
}
export interface HardcoverSearchResult {
id: number
title: string
authors: string[]
year: number | null
genres: string[]
}
export interface ImportSource {
id: string
name: string
type: SourceType
path: string
enabled: boolean
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5278',
changeOrigin: true,
},
},
},
})
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/__tests__/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/__tests__/**', 'src/main.tsx', 'src/data/mock.ts'],
},
},
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'