feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous - Split password hashing and pepper loading into passwords.py - Add a system/light/dark theme toggle backed by light-dark() colors - Ignore stale 401s from an earlier session, PATCH only changed book fields, and block overlapping journal-entry saves - Add bin/start and CoderPad Vite server settings for the pad's start/restart - Rewrite README as a mise onboarding guide; expand .gitignore - Include review-round fixes and tests
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Link, MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppRoutes } from "../App";
|
||||
import type { Book } from "../api";
|
||||
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||
import { AuthProvider } from "../auth/AuthContext";
|
||||
import { dune, held, renderApp, signedIn } from "../test/helpers";
|
||||
|
||||
function withProgress(current_page: number): Book {
|
||||
const status =
|
||||
@@ -23,14 +27,18 @@ describe("BookDetail", () => {
|
||||
],
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
expect(await screen.findByText("142 / 380 (37%)")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Page 142 of 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(
|
||||
await screen.findByText("Page 200 of 380 · 53%"),
|
||||
).toBeInTheDocument();
|
||||
expect(calls).toContainEqual({
|
||||
method: "PATCH",
|
||||
path: "/api/books/7",
|
||||
@@ -47,7 +55,9 @@ describe("BookDetail", () => {
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Mark finished" }),
|
||||
);
|
||||
expect(await screen.findByText("380 / 380 (100%)")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Page 380 of 380 · 100%"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Finished")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -110,3 +120,68 @@ describe("BookDetail", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BookDetail progress saves", () => {
|
||||
it("allows one progress save at a time so a late response cannot revert newer progress", async () => {
|
||||
const first = held();
|
||||
const calls = signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => [200, []],
|
||||
"PATCH /api/books/7": () => first.reply,
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
const input = await screen.findByLabelText("Current page");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10");
|
||||
await user.click(screen.getByRole("button", { name: "Save progress" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save progress" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Mark finished" }),
|
||||
).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Mark finished" }));
|
||||
expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(1);
|
||||
|
||||
first.release([200, withProgress(10)]);
|
||||
|
||||
expect(await screen.findByText("Page 10 of 380 · 3%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save progress" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BookDetail navigation", () => {
|
||||
it("does not keep the previous book actionable while the next one loads", async () => {
|
||||
const next = held();
|
||||
signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => [200, []],
|
||||
"GET /api/books/8": () => next.reply,
|
||||
"GET /api/books/8/notes": () => [200, []],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/books/7"]}>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<Link to="/books/8">Next book</Link>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Dune", level: 1 });
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "Next book" }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Dune" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save progress" }),
|
||||
).not.toBeInTheDocument();
|
||||
next.release([200, { ...dune, id: 8, title: "Dune Messiah" }]);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+43
-30
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { type SubmitEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { api, ApiError, errorMessage, type Book } from "../api";
|
||||
import { ApiError, api, type Book, errorMessage } from "../api";
|
||||
import { AlertIcon, TrashIcon } from "../icons";
|
||||
import { NotesJournal } from "../notes/NotesJournal";
|
||||
import {
|
||||
card,
|
||||
dangerButton,
|
||||
errorText,
|
||||
inputClass,
|
||||
labelText,
|
||||
linkText,
|
||||
pageTitle,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
} from "../ui";
|
||||
import { NotesJournal } from "../notes/NotesJournal";
|
||||
import { ProgressBar, StatusBadge } from "./Progress";
|
||||
|
||||
export function BookDetail() {
|
||||
@@ -23,6 +27,7 @@ export function BookDetail() {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [page, setPage] = useState("");
|
||||
const [progressError, setProgressError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
@@ -51,11 +56,8 @@ export function BookDetail() {
|
||||
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"
|
||||
>
|
||||
<h1 className={pageTitle}>Book not found</h1>
|
||||
<Link to="/books" className={`mt-3 inline-block ${linkText}`}>
|
||||
← Back to library
|
||||
</Link>
|
||||
</div>
|
||||
@@ -63,8 +65,11 @@ export function BookDetail() {
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||
<span>{loadError.message}</span>
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||
<span className={errorText}>
|
||||
<AlertIcon />
|
||||
{loadError.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
@@ -75,10 +80,12 @@ export function BookDetail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!book) return <p className="text-slate-500">Loading…</p>;
|
||||
if (!book) return <p className="text-muted">Loading…</p>;
|
||||
const current = book;
|
||||
|
||||
async function saveProgress(currentPage: number) {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setProgressError(null);
|
||||
try {
|
||||
const updated = await api<Book>("PATCH", `/books/${current.id}`, {
|
||||
@@ -88,6 +95,8 @@ export function BookDetail() {
|
||||
setPage(String(updated.current_page));
|
||||
} catch (err) {
|
||||
setProgressError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,28 +110,33 @@ export function BookDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function submitProgress(event: FormEvent) {
|
||||
function submitProgress(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void saveProgress(Number(page));
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="space-y-6">
|
||||
<Link to="/books" className="text-sm text-indigo-700 underline">
|
||||
<article className="space-y-7">
|
||||
<Link
|
||||
to="/books"
|
||||
className={`inline-flex min-h-11 items-center ${linkText}`}
|
||||
>
|
||||
← Back to library
|
||||
</Link>
|
||||
<header className={card}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<header className={`${card} space-y-5 sm:p-7`}>
|
||||
<div className="flex flex-wrap 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>
|
||||
<h1 className="font-serif text-4xl leading-tight font-medium">
|
||||
{book.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-muted">{`${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"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label>
|
||||
<span className={labelText}>Current page</span>
|
||||
@@ -138,28 +152,25 @@ export function BookDetail() {
|
||||
aria-describedby={progressError ? "progress-error" : undefined}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className={primaryButton}>
|
||||
<button type="submit" disabled={saving} className={primaryButton}>
|
||||
Save progress
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
disabled={book.current_page === book.total_pages}
|
||||
disabled={saving || 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"
|
||||
>
|
||||
<p id="progress-error" role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{progressError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="flex flex-wrap gap-3 border-t border-line pt-5">
|
||||
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
||||
Edit
|
||||
</Link>
|
||||
@@ -168,6 +179,7 @@ export function BookDetail() {
|
||||
className={dangerButton}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete book
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,10 +187,10 @@ export function BookDetail() {
|
||||
<dialog
|
||||
open
|
||||
aria-labelledby="delete-prompt"
|
||||
className={`relative mt-4 ${card} ring-2 ring-red-200`}
|
||||
className="relative rounded-xl border-2 border-danger bg-surface p-6 text-ink"
|
||||
>
|
||||
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={dangerButton}
|
||||
@@ -195,7 +207,8 @@ export function BookDetail() {
|
||||
</button>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-700">
|
||||
<p role="alert" className={`mt-3 ${errorText}`}>
|
||||
<AlertIcon />
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -79,13 +79,7 @@ describe("BookForm", () => {
|
||||
expect(calls).toContainEqual({
|
||||
method: "PATCH",
|
||||
path: "/api/books/7",
|
||||
body: {
|
||||
title: "Dune Messiah",
|
||||
author: "Frank Herbert",
|
||||
genre_id: 5,
|
||||
total_pages: 380,
|
||||
current_page: 142,
|
||||
},
|
||||
body: { title: "Dune Messiah" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,4 +98,41 @@ describe("BookForm", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByLabelText("Title")).toHaveValue("Dune");
|
||||
});
|
||||
|
||||
it("saving without changes returns to the book without a request", async () => {
|
||||
const calls = signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => [200, []],
|
||||
});
|
||||
const user = renderApp("/books/7/edit");
|
||||
await screen.findByDisplayValue("Dune");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Dune", level: 1 });
|
||||
expect(calls.some((call) => call.method === "PATCH")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves progress saved in another session when only the title changes", async () => {
|
||||
let saved = { ...dune };
|
||||
signedIn({
|
||||
"GET /api/books/7": () => [200, { ...saved }],
|
||||
"GET /api/books/7/notes": () => [200, []],
|
||||
"PATCH /api/books/7": ({ body }) => {
|
||||
saved = { ...saved, ...(body as object) };
|
||||
return [200, saved];
|
||||
},
|
||||
});
|
||||
const user = renderApp("/books/7/edit");
|
||||
const title = await screen.findByDisplayValue("Dune");
|
||||
await screen.findByRole("option", { name: "Science Fiction" });
|
||||
|
||||
// Another session saves progress after this form loads its snapshot.
|
||||
saved = { ...saved, current_page: 300 };
|
||||
await user.clear(title);
|
||||
await user.type(title, "Dune Messiah");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 });
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("value", "300");
|
||||
});
|
||||
});
|
||||
|
||||
+46
-25
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { type SubmitEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { api, ApiError, errorMessage, type Book } from "../api";
|
||||
import { ApiError, api, type Book, errorMessage } from "../api";
|
||||
import { AlertIcon } from "../icons";
|
||||
import {
|
||||
card,
|
||||
errorText,
|
||||
inputClass,
|
||||
labelText,
|
||||
linkText,
|
||||
pageTitle,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
} from "../ui";
|
||||
@@ -33,6 +37,7 @@ export function BookForm() {
|
||||
const navigate = useNavigate();
|
||||
const { genres, error: genresError } = useGenres();
|
||||
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||
const [loaded, setLoaded] = useState<Fields>(EMPTY);
|
||||
const [loading, setLoading] = useState(editing);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<FormError | null>(null);
|
||||
@@ -47,13 +52,15 @@ export function BookForm() {
|
||||
api<Book>("GET", `/books/${id}`).then(
|
||||
(book) => {
|
||||
if (cancelled) return;
|
||||
setFields({
|
||||
const current = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
genre_id: String(book.genre.id),
|
||||
total_pages: String(book.total_pages),
|
||||
current_page: String(book.current_page),
|
||||
});
|
||||
};
|
||||
setFields(current);
|
||||
setLoaded(current);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
@@ -78,10 +85,8 @@ export function BookForm() {
|
||||
: {};
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
title: fields.title,
|
||||
author: fields.author,
|
||||
@@ -89,9 +94,23 @@ export function BookForm() {
|
||||
total_pages: Number(fields.total_pages),
|
||||
current_page: Number(fields.current_page),
|
||||
};
|
||||
// An edit sends only what the user changed, so it cannot overwrite fields
|
||||
// (such as progress) saved elsewhere after this form loaded.
|
||||
const changes = Object.fromEntries(
|
||||
Object.entries(payload).filter(
|
||||
([name]) =>
|
||||
fields[name as keyof Fields] !== loaded[name as keyof Fields],
|
||||
),
|
||||
);
|
||||
if (editing && Object.keys(changes).length === 0) {
|
||||
navigate(`/books/${id}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = editing
|
||||
? await api<Book>("PATCH", `/books/${id}`, payload)
|
||||
? await api<Book>("PATCH", `/books/${id}`, changes)
|
||||
: await api<Book>("POST", "/books", payload);
|
||||
navigate(`/books/${saved.id}`);
|
||||
} catch (err) {
|
||||
@@ -106,8 +125,11 @@ export function BookForm() {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div role="alert" className={card}>
|
||||
<p className="text-red-700">{loadError}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<p className={errorText}>
|
||||
<AlertIcon />
|
||||
{loadError}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
@@ -115,20 +137,18 @@ export function BookForm() {
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<Link to="/books" className="text-indigo-700 underline">
|
||||
<Link to="/books" className={linkText}>
|
||||
← Back to library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <p className="text-slate-500">Loading…</p>;
|
||||
if (loading) return <p className="text-muted">Loading…</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className={`space-y-4 ${card}`}>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{editing ? "Edit book" : "Add a book"}
|
||||
</h1>
|
||||
<form onSubmit={submit} className={`mx-auto max-w-xl space-y-5 ${card}`}>
|
||||
<h1 className={pageTitle}>{editing ? "Edit book" : "Add a book"}</h1>
|
||||
<label className="block">
|
||||
<span className={labelText}>Title</span>
|
||||
<input
|
||||
@@ -169,13 +189,13 @@ export function BookForm() {
|
||||
</select>
|
||||
</label>
|
||||
{genresError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>{`Could not load genres: ${genresError}`}</p>
|
||||
<p role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{`Could not load genres: ${genresError}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<label className="block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block min-w-36 flex-1">
|
||||
<span className={labelText}>Total pages</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -188,7 +208,7 @@ export function BookForm() {
|
||||
{...invalid("total_pages")}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<label className="block min-w-36 flex-1">
|
||||
<span className={labelText}>Current page</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -203,11 +223,12 @@ export function BookForm() {
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p id="form-error" role="alert" className="text-sm text-red-700">
|
||||
<p id="form-error" role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<button type="submit" disabled={busy} className={primaryButton}>
|
||||
{editing ? "Save changes" : "Add book"}
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("BookList", () => {
|
||||
screen.getByText("Frank Herbert · Science Fiction"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Reading")).toBeInTheDocument();
|
||||
expect(screen.getByText("142 / 380 (37%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("37%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Dune/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/books/7",
|
||||
|
||||
+60
-41
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { api, errorMessage, type Book } from "../api";
|
||||
import { api, type Book, errorMessage } from "../api";
|
||||
import { AlertIcon, PlusIcon, SearchIcon } from "../icons";
|
||||
import {
|
||||
card,
|
||||
errorText,
|
||||
inputClass,
|
||||
labelText,
|
||||
pageTitle,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
} from "../ui";
|
||||
@@ -23,17 +25,19 @@ export function BookList() {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
const updateParam = useCallback(
|
||||
(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 },
|
||||
),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
useEffect(() => setSearch(q), [q]);
|
||||
|
||||
@@ -44,7 +48,7 @@ export function BookList() {
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, q]);
|
||||
}, [search, q, updateParam]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,18 +71,30 @@ export function BookList() {
|
||||
}, [q, genre, attempt]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<label className="min-w-48 grow">
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<h1 className={pageTitle}>Your library</h1>
|
||||
<Link to="/books/new" className={primaryButton}>
|
||||
<PlusIcon />
|
||||
Add book
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="min-w-0 flex-[1_1_280px]">
|
||||
<span className={labelText}>Search title or author</span>
|
||||
<input
|
||||
type="search"
|
||||
className={inputClass}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<span className="relative block">
|
||||
<span className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className={`${inputClass} pl-11`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<label className="flex-[0_1_220px]">
|
||||
<span className={labelText}>Genre</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
@@ -93,18 +109,19 @@ export function BookList() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Link to="/books/new" className={primaryButton}>
|
||||
Add book
|
||||
</Link>
|
||||
</div>
|
||||
{genresError && (
|
||||
<p role="alert" className="mb-3 text-sm text-red-700">
|
||||
<p role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{`Could not load genres: ${genresError}`}
|
||||
</p>
|
||||
)}
|
||||
{error ? (
|
||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||
<span>{error}</span>
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||
<span className={errorText}>
|
||||
<AlertIcon />
|
||||
{error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
@@ -114,27 +131,29 @@ export function BookList() {
|
||||
</button>
|
||||
</div>
|
||||
) : books === null ? (
|
||||
<p className="text-slate-500">Loading…</p>
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : books.length === 0 ? (
|
||||
<p className="text-slate-600">
|
||||
<p className="text-muted">
|
||||
{q.trim() || genre
|
||||
? "No matches. Try a different search or genre."
|
||||
: "No books yet. Add your first one."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="overflow-hidden rounded-xl border border-line bg-surface">
|
||||
{books.map((book) => (
|
||||
<li key={book.id}>
|
||||
<li key={book.id} className="border-b border-line last:border-b-0">
|
||||
<Link
|
||||
to={`/books/${book.id}`}
|
||||
className={`block ${card} hover:ring-2 hover:ring-indigo-300 focus-visible:outline-2 focus-visible:outline-indigo-600`}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-3 px-5 py-4 hover:bg-line/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold">{book.title}</h2>
|
||||
<StatusBadge status={book.status} />
|
||||
<div className="min-w-0 flex-[1_1_260px]">
|
||||
<h2 className="font-serif text-xl leading-snug font-semibold">
|
||||
{book.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted">{`${book.author} · ${book.genre.name}`}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
|
||||
<ProgressBar book={book} />
|
||||
<StatusBadge status={book.status} />
|
||||
<ProgressBar book={book} compact />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReadingStatus } from "../api";
|
||||
import { StatusBadge } from "./Progress";
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each<[ReadingStatus, string]>([
|
||||
["not_started", "Not started"],
|
||||
["reading", "Reading"],
|
||||
["finished", "Finished"],
|
||||
])("shows %s as a text label with a decorative icon", (status, label) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
const badge = screen.getByText(label);
|
||||
expect(badge.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+39
-9
@@ -1,32 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Book, ReadingStatus } from "../api";
|
||||
import { FinishedIcon, NotStartedIcon, ReadingIcon } from "../icons";
|
||||
|
||||
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
|
||||
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" },
|
||||
const STATUS: Record<
|
||||
ReadingStatus,
|
||||
{ label: string; style: string; icon: ReactNode }
|
||||
> = {
|
||||
not_started: {
|
||||
label: "Not started",
|
||||
style: "bg-idle-bg text-idle-fg",
|
||||
icon: <NotStartedIcon />,
|
||||
},
|
||||
reading: {
|
||||
label: "Reading",
|
||||
style: "bg-reading-bg text-reading-fg",
|
||||
icon: <ReadingIcon />,
|
||||
},
|
||||
finished: {
|
||||
label: "Finished",
|
||||
style: "bg-finished-bg text-finished-fg",
|
||||
icon: <FinishedIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-sm font-bold whitespace-nowrap ${STATUS[status].style}`}
|
||||
>
|
||||
{STATUS[status].icon}
|
||||
{STATUS[status].label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({ book }: { book: Book }) {
|
||||
export function ProgressBar({
|
||||
book,
|
||||
compact = false,
|
||||
}: {
|
||||
book: Book;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const percent = Math.round((book.current_page / book.total_pages) * 100);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className={compact ? "flex flex-[0_1_200px] items-center gap-3" : ""}>
|
||||
<progress
|
||||
className="h-2 w-full accent-indigo-600"
|
||||
className={`block w-full appearance-none overflow-hidden rounded-full bg-track ${compact ? "h-1.5 flex-1" : "h-2"} [&::-moz-progress-bar]:bg-accent [&::-webkit-progress-bar]:bg-track [&::-webkit-progress-value]:bg-accent`}
|
||||
value={book.current_page}
|
||||
max={book.total_pages}
|
||||
aria-label={`Reading progress for ${book.title}`}
|
||||
/>
|
||||
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
|
||||
<p
|
||||
className={`text-sm text-muted tabular-nums ${compact ? "min-w-10 text-right" : "mt-1.5"}`}
|
||||
>
|
||||
{compact
|
||||
? `${percent}%`
|
||||
: `Page ${book.current_page} of ${book.total_pages} · ${percent}%`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user