feat: add book detail with progress updates and delete
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
@@ -5,6 +5,7 @@ import { AuthProvider, useAuth } from './auth/AuthContext'
|
|||||||
import { LoginForm } from './auth/LoginForm'
|
import { LoginForm } from './auth/LoginForm'
|
||||||
import { RequireAuth } from './auth/RequireAuth'
|
import { RequireAuth } from './auth/RequireAuth'
|
||||||
import { BookList } from './books/BookList'
|
import { BookList } from './books/BookList'
|
||||||
|
import { BookDetail } from './books/BookDetail'
|
||||||
import { secondaryButton } from './ui'
|
import { secondaryButton } from './ui'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -24,6 +25,7 @@ export function AppRoutes() {
|
|||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<Shell />}>
|
<Route element={<Shell />}>
|
||||||
<Route path="/books" element={<BookList />} />
|
<Route path="/books" element={<BookList />} />
|
||||||
|
<Route path="/books/:id" element={<BookDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/books" replace />} />
|
<Route path="*" element={<Navigate to="/books" replace />} />
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Book } from '../api'
|
||||||
|
import { dune, renderApp, signedIn } from '../test/helpers'
|
||||||
|
|
||||||
|
function withProgress(current_page: number): Book {
|
||||||
|
const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading'
|
||||||
|
return { ...dune, current_page, status }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookDetail', () => {
|
||||||
|
it('updates progress and reflects it in the bar', async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Current page')
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, '200')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save progress' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument()
|
||||||
|
expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a book finished', async () => {
|
||||||
|
signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'PATCH /api/books/7': () => [200, withProgress(380)],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Mark finished' }))
|
||||||
|
expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Finished')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the server message when a progress update is rejected', async () => {
|
||||||
|
signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Save progress' }))
|
||||||
|
// findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert.
|
||||||
|
expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert')
|
||||||
|
expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows not found for a missing or foreign book', async () => {
|
||||||
|
signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] })
|
||||||
|
renderApp('/books/999')
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes after confirmation and returns to the list', async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'DELETE /api/books/7': () => [204],
|
||||||
|
'GET /api/books': () => [200, []],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Delete book' }))
|
||||||
|
expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Yes, delete' }))
|
||||||
|
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
|
import { api, ApiError, errorMessage, type Book } from '../api'
|
||||||
|
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
||||||
|
import { ProgressBar, StatusBadge } from './Progress'
|
||||||
|
|
||||||
|
export function BookDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [book, setBook] = useState<Book | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
const [page, setPage] = useState('')
|
||||||
|
const [progressError, setProgressError] = useState<string | null>(null)
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoadError(null)
|
||||||
|
api<Book>('GET', `/books/${id}`).then(
|
||||||
|
(found) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setBook(found)
|
||||||
|
setPage(String(found.current_page))
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled) setLoadError({ notFound: err instanceof ApiError && err.status === 404, message: errorMessage(err) })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id, attempt])
|
||||||
|
|
||||||
|
if (loadError?.notFound) {
|
||||||
|
return (
|
||||||
|
<div className={card}>
|
||||||
|
<h1 className="text-xl font-semibold">Book not found</h1>
|
||||||
|
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
|
||||||
|
← Back to library
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||||
|
<span>{loadError.message}</span>
|
||||||
|
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!book) return <p className="text-slate-500">Loading…</p>
|
||||||
|
const current = book
|
||||||
|
|
||||||
|
async function saveProgress(currentPage: number) {
|
||||||
|
setProgressError(null)
|
||||||
|
try {
|
||||||
|
const updated = await api<Book>('PATCH', `/books/${current.id}`, { current_page: currentPage })
|
||||||
|
setBook(updated)
|
||||||
|
setPage(String(updated.current_page))
|
||||||
|
} catch (err) {
|
||||||
|
setProgressError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBook() {
|
||||||
|
setDeleteError(null)
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/books/${current.id}`)
|
||||||
|
navigate('/books', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitProgress(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
void saveProgress(Number(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="space-y-6">
|
||||||
|
<Link to="/books" className="text-sm text-indigo-700 underline">
|
||||||
|
← Back to library
|
||||||
|
</Link>
|
||||||
|
<header className={card}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{book.title}</h1>
|
||||||
|
<p className="text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={book.status} />
|
||||||
|
</div>
|
||||||
|
<ProgressBar book={book} />
|
||||||
|
<form onSubmit={submitProgress} className="mt-4 flex flex-wrap items-end gap-2">
|
||||||
|
<label>
|
||||||
|
<span className={labelText}>Current page</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputClass} w-32`}
|
||||||
|
min={0}
|
||||||
|
max={book.total_pages}
|
||||||
|
value={page}
|
||||||
|
onChange={(e) => setPage(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-invalid={progressError ? true : undefined}
|
||||||
|
aria-describedby={progressError ? 'progress-error' : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={primaryButton}>
|
||||||
|
Save progress
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
disabled={book.current_page === book.total_pages}
|
||||||
|
onClick={() => void saveProgress(book.total_pages)}
|
||||||
|
>
|
||||||
|
Mark finished
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{progressError && (
|
||||||
|
<p id="progress-error" role="alert" className="mt-2 text-sm text-red-700">
|
||||||
|
{progressError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button type="button" className={dangerButton} onClick={() => setConfirmingDelete(true)}>
|
||||||
|
Delete book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{confirmingDelete && (
|
||||||
|
<dialog open aria-labelledby="delete-prompt" className={`relative mt-4 ${card} ring-2 ring-red-200`}>
|
||||||
|
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button type="button" className={dangerButton} onClick={() => void deleteBook()}>
|
||||||
|
Yes, delete
|
||||||
|
</button>
|
||||||
|
<button type="button" className={secondaryButton} onClick={() => setConfirmingDelete(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{deleteError && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-700">
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user