diff --git a/package-lock.json b/package-lock.json
index 230c645..957034d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
- "@testing-library/jest-dom": "^6.10.0",
+ "@testing-library/jest-dom": "~6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^24.10.1",
@@ -1768,10 +1768,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
- "version": "6.10.0",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz",
- "integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==",
- "deprecated": "Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0.",
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1783,12 +1782,9 @@
"redent": "^3.0.0"
},
"engines": {
- "node": ">=22",
+ "node": ">=14",
"npm": ">=6",
"yarn": ">=1"
- },
- "peerDependencies": {
- "@testing-library/dom": ">=10 <11"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
diff --git a/package.json b/package.json
index 27a132f..877c47e 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
- "@testing-library/jest-dom": "^6.10.0",
+ "@testing-library/jest-dom": "~6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^24.10.1",
diff --git a/src/App.tsx b/src/App.tsx
index 6a5441a..3c91ae6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,3 +1,66 @@
+import { useState } from 'react'
+import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from 'react-router'
+import { errorMessage } from './api'
+import { AuthProvider, useAuth } from './auth/AuthContext'
+import { LoginForm } from './auth/LoginForm'
+import { RequireAuth } from './auth/RequireAuth'
+import { BookList } from './books/BookList'
+import { secondaryButton } from './ui'
+
export function App() {
- return
My Library
+ return (
+
+
+
+
+
+ )
+}
+
+export function AppRoutes() {
+ return (
+
+ } />
+ }>
+ }>
+ } />
+
+
+ } />
+
+ )
+}
+
+function Shell() {
+ const { user, logout } = useAuth()
+ const [error, setError] = useState(null)
+ return (
+
+ )
}
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..2342afd
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,54 @@
+export type User = { id: number; username: string }
+export type Genre = { id: number; name: string }
+export type ReadingStatus = 'not_started' | 'reading' | 'finished'
+export type Book = {
+ id: number
+ title: string
+ author: string
+ genre: Genre
+ total_pages: number
+ current_page: number
+ status: ReadingStatus
+ created_at: string
+ updated_at: string
+}
+export type Note = { id: number; book_id: number; body: string; created_at: string; updated_at: string }
+
+export class ApiError extends Error {
+ readonly status: number
+ readonly field?: string
+
+ constructor(status: number, message: string, field?: string) {
+ super(message)
+ this.status = status
+ this.field = field
+ }
+}
+
+let handleUnauthorized: () => void = () => {}
+
+export function setUnauthorizedHandler(handler: () => void) {
+ handleUnauthorized = handler
+}
+
+export async function api(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise {
+ const response = await fetch(`/api${path}`, {
+ method,
+ credentials: 'same-origin',
+ // Always JSON: the server rejects mutations without it (CSRF defense, ADR-0002).
+ headers: { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+ if (response.status === 204) return undefined as T
+ const data = await response.json().catch(() => null)
+ if (!response.ok) {
+ // 401s from /auth/* are expected answers (bad login, not signed in yet), not an expired session.
+ if (response.status === 401 && !path.startsWith('/auth/')) handleUnauthorized()
+ throw new ApiError(response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field)
+ }
+ return data as T
+}
+
+export function errorMessage(error: unknown): string {
+ return error instanceof ApiError ? error.message : 'Could not reach the server; check your connection and try again'
+}
diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx
new file mode 100644
index 0000000..cd8daac
--- /dev/null
+++ b/src/auth/AuthContext.tsx
@@ -0,0 +1,42 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { api, setUnauthorizedHandler, type User } from '../api'
+
+type AuthValue = {
+ user: User | null
+ loading: boolean
+ login: (username: string, password: string) => Promise
+ register: (username: string, password: string) => Promise
+ logout: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ setUnauthorizedHandler(() => setUser(null))
+ api('GET', '/auth/me')
+ .then(setUser, () => setUser(null))
+ .finally(() => setLoading(false))
+ }, [])
+
+ const value: AuthValue = {
+ user,
+ loading,
+ login: async (username, password) => setUser(await api('POST', '/auth/login', { username, password })),
+ register: async (username, password) => setUser(await api('POST', '/auth/register', { username, password })),
+ logout: async () => {
+ await api('POST', '/auth/logout')
+ setUser(null)
+ },
+ }
+ return {children}
+}
+
+export function useAuth(): AuthValue {
+ const value = useContext(AuthContext)
+ if (!value) throw new Error('useAuth must be used inside ')
+ return value
+}
diff --git a/src/auth/LoginForm.tsx b/src/auth/LoginForm.tsx
new file mode 100644
index 0000000..4377d66
--- /dev/null
+++ b/src/auth/LoginForm.tsx
@@ -0,0 +1,85 @@
+import { useState, type FormEvent } from 'react'
+import { Navigate, useLocation, type Location } from 'react-router'
+import { errorMessage } from '../api'
+import { card, inputClass, labelText, primaryButton } from '../ui'
+import { useAuth } from './AuthContext'
+
+export function LoginForm() {
+ const { user, login, register } = useAuth()
+ const location = useLocation()
+ const [mode, setMode] = useState<'login' | 'register'>('login')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ if (user) {
+ const from = (location.state as { from?: Location } | null)?.from
+ return
+ }
+
+ const isLogin = mode === 'login'
+
+ async function submit(event: FormEvent) {
+ event.preventDefault()
+ setError(null)
+ setBusy(true)
+ try {
+ await (isLogin ? login : register)(username, password)
+ } catch (err) {
+ setError(errorMessage(err))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ {isLogin ? 'Sign in' : 'Create account'}
+
+
+
+
+ )
+}
diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx
new file mode 100644
index 0000000..af640df
--- /dev/null
+++ b/src/auth/RequireAuth.tsx
@@ -0,0 +1,10 @@
+import { Navigate, Outlet, useLocation } from 'react-router'
+import { useAuth } from './AuthContext'
+
+export function RequireAuth() {
+ const { user, loading } = useAuth()
+ const location = useLocation()
+ if (loading) return Loading…
+ if (!user) return
+ return
+}
diff --git a/src/auth/auth.test.tsx b/src/auth/auth.test.tsx
new file mode 100644
index 0000000..c2b0860
--- /dev/null
+++ b/src/auth/auth.test.tsx
@@ -0,0 +1,69 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers'
+
+describe('auth flow', () => {
+ it('sends signed-out users to sign in, then back to the page they wanted', async () => {
+ const calls = mockApi({
+ 'GET /api/auth/me': () => [401, { error: 'Not signed in or session expired; log in again' }],
+ 'POST /api/auth/login': () => [200, alice],
+ 'GET /api/genres': () => [200, genres],
+ 'GET /api/books': () => [200, [dune]],
+ })
+ const user = renderApp('/books?q=dune')
+
+ await screen.findByRole('heading', { name: 'Sign in' })
+ await user.type(screen.getByLabelText('Username'), 'alice')
+ await user.type(screen.getByLabelText('Password'), 'correct horse battery')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+
+ expect(await screen.findByLabelText('Search title or author')).toHaveValue('dune')
+ expect(calls).toContainEqual({
+ method: 'POST',
+ path: '/api/auth/login',
+ body: { username: 'alice', password: 'correct horse battery' },
+ })
+ })
+
+ it('shows the server error for a bad login', async () => {
+ mockApi({
+ 'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
+ 'POST /api/auth/login': () => [401, { error: 'Invalid username or password' }],
+ })
+ const user = renderApp('/login')
+ await user.type(await screen.findByLabelText('Username'), 'alice')
+ await user.type(screen.getByLabelText('Password'), 'wrong password!')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password')
+ })
+
+ it('registers a new account', async () => {
+ const calls = mockApi({
+ 'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
+ 'POST /api/auth/register': () => [201, alice],
+ 'GET /api/genres': () => [200, genres],
+ 'GET /api/books': () => [200, []],
+ })
+ const user = renderApp('/login')
+ await user.click(await screen.findByRole('button', { name: 'Need an account? Register' }))
+ await user.type(screen.getByLabelText('Username'), 'alice')
+ await user.type(screen.getByLabelText('Password'), 'correct horse battery')
+ await user.click(screen.getByRole('button', { name: 'Create account' }))
+ expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
+ expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/register')).toBe(true)
+ })
+
+ it('returns to sign in when the session expires mid-use', async () => {
+ signedIn({ 'GET /api/books': () => [401, { error: 'Not signed in or session expired; log in again' }] })
+ renderApp('/books')
+ expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
+ })
+
+ it('logs out', async () => {
+ const calls = signedIn({ 'GET /api/books': () => [200, []], 'POST /api/auth/logout': () => [204] })
+ const user = renderApp('/books')
+ await user.click(await screen.findByRole('button', { name: 'Log out' }))
+ expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
+ expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/logout')).toBe(true)
+ })
+})
diff --git a/src/books/BookList.test.tsx b/src/books/BookList.test.tsx
new file mode 100644
index 0000000..5471507
--- /dev/null
+++ b/src/books/BookList.test.tsx
@@ -0,0 +1,58 @@
+import { screen, waitFor } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { dune, renderApp, signedIn } from '../test/helpers'
+
+describe('BookList', () => {
+ it('shows each book with author, genre, status, and progress', async () => {
+ signedIn({ 'GET /api/books': () => [200, [dune]] })
+ renderApp('/books')
+ expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
+ expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
+ expect(screen.getByText('Reading')).toBeInTheDocument()
+ expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
+ })
+
+ it('distinguishes an empty library from no matches', async () => {
+ signedIn({ 'GET /api/books': () => [200, []] })
+ const user = renderApp('/books')
+ expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
+ await user.type(screen.getByLabelText('Search title or author'), 'zzz')
+ expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
+ })
+
+ it('debounces search into the URL and the request', async () => {
+ const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
+ const user = renderApp('/books')
+ await screen.findByRole('heading', { name: 'Dune' })
+ await user.type(screen.getByLabelText('Search title or author'), 'herbert')
+ await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
+ expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
+ })
+
+ it('filters by genre', async () => {
+ const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
+ const user = renderApp('/books')
+ await screen.findByRole('option', { name: 'Science Fiction' })
+ await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
+ await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
+ })
+
+ it('restores search and genre from the URL', async () => {
+ const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
+ renderApp('/books?q=dune&genre=5')
+ await screen.findByRole('option', { name: 'Science Fiction' })
+ expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
+ expect(screen.getByLabelText('Genre')).toHaveValue('5')
+ expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
+ })
+
+ it('offers retry when loading fails', async () => {
+ let attempts = 0
+ signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
+ const user = renderApp('/books')
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
+ })
+})
diff --git a/src/books/BookList.tsx b/src/books/BookList.tsx
new file mode 100644
index 0000000..df5543d
--- /dev/null
+++ b/src/books/BookList.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useState } from 'react'
+import { Link, useSearchParams } from 'react-router'
+import { api, errorMessage, type Book } from '../api'
+import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
+import { ProgressBar, StatusBadge } from './Progress'
+import { useGenres } from './useGenres'
+
+const SEARCH_DEBOUNCE_MS = 250
+
+export function BookList() {
+ const [params, setParams] = useSearchParams()
+ const q = params.get('q') ?? ''
+ const genre = params.get('genre') ?? ''
+ const [search, setSearch] = useState(q)
+ const [books, setBooks] = useState(null)
+ const [error, setError] = useState(null)
+ const [attempt, setAttempt] = useState(0)
+ const { genres, error: genresError } = useGenres()
+
+ function updateParam(key: string, value: string) {
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ if (value) next.set(key, value)
+ else next.delete(key)
+ return next
+ },
+ { replace: true },
+ )
+ }
+
+ // Keep the input in sync when the URL changes from outside (back/forward navigation).
+ useEffect(() => setSearch(q), [q])
+
+ useEffect(() => {
+ if (search === q) return
+ const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
+ return () => clearTimeout(timer)
+ }, [search, q])
+
+ useEffect(() => {
+ let cancelled = false
+ const query = new URLSearchParams()
+ if (q.trim()) query.set('q', q.trim())
+ if (genre) query.set('genre_id', genre)
+ api('GET', `/books?${query}`).then(
+ (found) => {
+ if (cancelled) return
+ setBooks(found)
+ setError(null)
+ },
+ (err) => {
+ if (!cancelled) setError(errorMessage(err))
+ },
+ )
+ return () => {
+ cancelled = true
+ }
+ }, [q, genre, attempt])
+
+ return (
+
+
+
+
+
+ Add book
+
+
+ {genresError && (
+
+ {`Could not load genres: ${genresError}`}
+
+ )}
+ {error ? (
+
+ {error}
+
+
+ ) : books === null ? (
+ Loading…
+ ) : books.length === 0 ? (
+
+ {q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/books/Progress.tsx b/src/books/Progress.tsx
new file mode 100644
index 0000000..bba1a2d
--- /dev/null
+++ b/src/books/Progress.tsx
@@ -0,0 +1,30 @@
+import type { Book, ReadingStatus } from '../api'
+
+const STATUS: Record = {
+ not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
+ reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
+ finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
+}
+
+export function StatusBadge({ status }: { status: ReadingStatus }) {
+ return (
+
+ {STATUS[status].label}
+
+ )
+}
+
+export function ProgressBar({ book }: { book: Book }) {
+ const percent = Math.round((book.current_page / book.total_pages) * 100)
+ return (
+
+
+
{`${book.current_page} / ${book.total_pages} (${percent}%)`}
+
+ )
+}
diff --git a/src/books/useGenres.ts b/src/books/useGenres.ts
new file mode 100644
index 0000000..c07fec4
--- /dev/null
+++ b/src/books/useGenres.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from 'react'
+import { api, errorMessage, type Genre } from '../api'
+
+export function useGenres(): { genres: Genre[]; error: string | null } {
+ const [genres, setGenres] = useState([])
+ const [error, setError] = useState(null)
+ useEffect(() => {
+ api('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
+ }, [])
+ return { genres, error }
+}
diff --git a/src/test/helpers.tsx b/src/test/helpers.tsx
new file mode 100644
index 0000000..6793f1b
--- /dev/null
+++ b/src/test/helpers.tsx
@@ -0,0 +1,65 @@
+import { render } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { vi } from 'vitest'
+import type { Book, Genre, User } from '../api'
+import { AppRoutes } from '../App'
+import { AuthProvider } from '../auth/AuthContext'
+
+export const alice: User = { id: 1, username: 'alice' }
+export const genres: Genre[] = [
+ { id: 3, name: 'Fantasy' },
+ { id: 5, name: 'Science Fiction' },
+]
+export const dune: Book = {
+ id: 7,
+ title: 'Dune',
+ author: 'Frank Herbert',
+ genre: { id: 5, name: 'Science Fiction' },
+ total_pages: 380,
+ current_page: 142,
+ status: 'reading',
+ created_at: '2026-10-01T10:00:00+00:00',
+ updated_at: '2026-10-01T10:00:00+00:00',
+}
+
+type Reply = [status: number, body?: unknown]
+type Handler = (request: { body: unknown; url: URL }) => Reply
+export type Call = { method: string; path: string; body: unknown }
+
+/** Replaces fetch (the network boundary) with route handlers keyed by "METHOD /api/path". */
+export function mockApi(routes: Record): Call[] {
+ const calls: Call[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string, init: RequestInit = {}) => {
+ const url = new URL(input, 'http://localhost')
+ const method = init.method ?? 'GET'
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
+ calls.push({ method, path: url.pathname + url.search, body })
+ const handler = routes[`${method} ${url.pathname}`]
+ const [status, data] = handler ? handler({ body, url }) : [500, { error: `No mock for ${method} ${url.pathname}` }]
+ return new Response(status === 204 ? null : JSON.stringify(data), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }),
+ )
+ return calls
+}
+
+export function signedIn(routes: Record = {}): Call[] {
+ return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes })
+}
+
+export function renderApp(url: string) {
+ const user = userEvent.setup()
+ render(
+
+
+
+
+ ,
+ )
+ return user
+}
diff --git a/src/ui.ts b/src/ui.ts
new file mode 100644
index 0000000..f56cdd4
--- /dev/null
+++ b/src/ui.ts
@@ -0,0 +1,9 @@
+export const card = 'rounded-lg bg-white p-4 shadow'
+export const labelText = 'mb-1 block text-sm font-medium text-slate-700'
+export const inputClass =
+ 'block w-full rounded-md border border-slate-300 px-3 py-2 focus-visible:outline-2 focus-visible:outline-indigo-600 aria-invalid:border-red-600'
+const button =
+ 'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50'
+export const primaryButton = `${button} bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600`
+export const secondaryButton = `${button} border border-slate-300 bg-white text-slate-800 hover:bg-slate-100 focus-visible:outline-indigo-600`
+export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`