feat: add sign-in flow, routing shell, and searchable book list

Pin @testing-library/jest-dom to ~6.9.1 (6.10.0 is npm-deprecated).

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:23:42 -05:00
parent e1096da1a7
commit f0efa25f23
14 changed files with 625 additions and 11 deletions
+54
View File
@@ -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<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
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'
}