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:
2026-10-02 16:49:14 -05:00
parent 9d7b0e805c
commit 7f5d034a1f
46 changed files with 1797 additions and 497 deletions
+80 -5
View File
@@ -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
View File
@@ -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>
)}
+38 -7
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
))}
+16
View File
@@ -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
View File
@@ -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>
);
}