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:
+43
-13
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Link,
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
useParams,
|
||||
} 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 { BookDetail } from "./books/BookDetail";
|
||||
import { BookForm } from "./books/BookForm";
|
||||
import { secondaryButton } from "./ui";
|
||||
import { BookList } from "./books/BookList";
|
||||
import { AlertIcon, BookIcon } from "./icons";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { errorText, secondaryButton } from "./ui";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -34,8 +37,22 @@ export function AppRoutes() {
|
||||
<Route element={<Shell />}>
|
||||
<Route path="/books" element={<BookList />} />
|
||||
<Route path="/books/new" element={<BookForm />} />
|
||||
<Route path="/books/:id/edit" element={<BookForm />} />
|
||||
<Route path="/books/:id" element={<BookDetail />} />
|
||||
<Route
|
||||
path="/books/:id/edit"
|
||||
element={
|
||||
<KeyedByBookId>
|
||||
<BookForm />
|
||||
</KeyedByBookId>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/books/:id"
|
||||
element={
|
||||
<KeyedByBookId>
|
||||
<BookDetail />
|
||||
</KeyedByBookId>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/books" replace />} />
|
||||
@@ -43,18 +60,30 @@ export function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function KeyedByBookId({ children }: { children: ReactNode }) {
|
||||
const { id } = useParams();
|
||||
return <Fragment key={id}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { user, logout } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between p-4">
|
||||
<Link to="/books" className="text-lg font-semibold">
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b border-line">
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3 px-6 py-3.5">
|
||||
<Link
|
||||
to="/books"
|
||||
className="flex items-center gap-2.5 rounded-lg font-serif text-2xl font-semibold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="text-accent">
|
||||
<BookIcon />
|
||||
</span>
|
||||
My Library
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>{user?.username}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<span className="text-muted">{user?.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
@@ -69,13 +98,14 @@ function Shell() {
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700"
|
||||
className={`mx-auto max-w-4xl px-6 pb-3 ${errorText}`}
|
||||
>
|
||||
<AlertIcon />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<main className="mx-auto max-w-4xl p-4">
|
||||
<main className="mx-auto max-w-4xl px-6 pt-11 pb-18">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const root = document.documentElement;
|
||||
const stored = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
// Vitest's jsdom on Node 24 exposes no localStorage, so give it a minimal one.
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => stored.set(key, value),
|
||||
removeItem: (key: string) => stored.delete(key),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete root.dataset.theme;
|
||||
stored.clear();
|
||||
});
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
it("cycles system, light, dark and back to following the system", async () => {
|
||||
render(<ThemeToggle />);
|
||||
const button = screen.getByRole("button", { name: /^Theme: System/ });
|
||||
expect(root.dataset.theme).toBeUndefined();
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(button).toHaveAccessibleName("Theme: Light. Switch to Dark");
|
||||
expect(root.dataset.theme).toBe("light");
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(root.dataset.theme).toBe("dark");
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(root.dataset.theme).toBeUndefined();
|
||||
expect(localStorage.getItem("theme")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts from the theme index.html applied before first paint", () => {
|
||||
root.dataset.theme = "dark";
|
||||
render(<ThemeToggle />);
|
||||
expect(screen.getByRole("button", { name: /^Theme: Dark/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MoonIcon, SunIcon, SystemThemeIcon } from "./icons";
|
||||
|
||||
const THEMES = {
|
||||
system: { label: "System", Icon: SystemThemeIcon, next: "light" },
|
||||
light: { label: "Light", Icon: SunIcon, next: "dark" },
|
||||
dark: { label: "Dark", Icon: MoonIcon, next: "system" },
|
||||
} as const;
|
||||
type Theme = keyof typeof THEMES;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (root.dataset.theme as Theme | undefined) ?? "system",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "system") delete root.dataset.theme;
|
||||
else root.dataset.theme = theme;
|
||||
try {
|
||||
if (theme === "system") localStorage.removeItem("theme");
|
||||
else localStorage.setItem("theme", theme);
|
||||
} catch {
|
||||
// Storage can be blocked (private mode); the choice still applies for this visit.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const { label, Icon, next } = THEMES[theme];
|
||||
const hint = `Theme: ${label}. Switch to ${THEMES[next].label}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={hint}
|
||||
title={hint}
|
||||
onClick={() => setTheme(next)}
|
||||
className="inline-flex size-11 items-center justify-center rounded-lg text-muted hover:bg-line hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+15
-1
@@ -32,6 +32,13 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
let handleUnauthorized: () => void = () => {};
|
||||
let authEpoch = 0;
|
||||
|
||||
// A 401 only means "signed out" for the session that sent the request; one started before
|
||||
// a later login, logout or expiry must not end the session that replaced it.
|
||||
export function startAuthEpoch() {
|
||||
authEpoch += 1;
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
handleUnauthorized = handler;
|
||||
@@ -42,6 +49,7 @@ export async function api<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const epoch = authEpoch;
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
@@ -51,8 +59,14 @@ export async function api<T>(
|
||||
if (response.status === 204) return undefined as T;
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !path.startsWith("/auth/"))
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!path.startsWith("/auth/") &&
|
||||
epoch === authEpoch
|
||||
) {
|
||||
startAuthEpoch();
|
||||
handleUnauthorized();
|
||||
}
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
data?.error ?? `Request failed with status ${response.status}`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api, setUnauthorizedHandler, type User } from "../api";
|
||||
import { api, setUnauthorizedHandler, startAuthEpoch, type User } from "../api";
|
||||
|
||||
type AuthValue = {
|
||||
user: User | null;
|
||||
@@ -20,26 +21,42 @@ const AuthContext = createContext<AuthValue | null>(null);
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const authChanged = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
api<User>("GET", "/auth/me")
|
||||
.then(setUser, () => setUser(null))
|
||||
.then(
|
||||
(me) => {
|
||||
if (!authChanged.current) setUser(me);
|
||||
},
|
||||
() => {
|
||||
if (!authChanged.current) setUser(null);
|
||||
},
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function changeUser(next: User | null) {
|
||||
authChanged.current = true;
|
||||
startAuthEpoch();
|
||||
setUser(next);
|
||||
}
|
||||
|
||||
const value: AuthValue = {
|
||||
user,
|
||||
loading,
|
||||
login: async (username, password) =>
|
||||
setUser(await api<User>("POST", "/auth/login", { username, password })),
|
||||
changeUser(
|
||||
await api<User>("POST", "/auth/login", { username, password }),
|
||||
),
|
||||
register: async (username, password) =>
|
||||
setUser(
|
||||
changeUser(
|
||||
await api<User>("POST", "/auth/register", { username, password }),
|
||||
),
|
||||
logout: async () => {
|
||||
await api("POST", "/auth/logout");
|
||||
setUser(null);
|
||||
changeUser(null);
|
||||
},
|
||||
};
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
+29
-10
@@ -1,7 +1,16 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate, useLocation, type Location } from "react-router";
|
||||
import { type SubmitEvent, useState } from "react";
|
||||
import { type Location, Navigate, useLocation } from "react-router";
|
||||
import { errorMessage } from "../api";
|
||||
import { card, inputClass, labelText, primaryButton } from "../ui";
|
||||
import { AlertIcon, BookIcon } from "../icons";
|
||||
import { ThemeToggle } from "../ThemeToggle";
|
||||
import {
|
||||
card,
|
||||
errorText,
|
||||
inputClass,
|
||||
labelText,
|
||||
linkText,
|
||||
primaryButton,
|
||||
} from "../ui";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -22,7 +31,7 @@ export function LoginForm() {
|
||||
|
||||
const isLogin = mode === "login";
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
@@ -36,12 +45,21 @@ export function LoginForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 px-4 pt-16">
|
||||
<main className={`mx-auto max-w-sm ${card}`}>
|
||||
<h1 className="mb-4 text-2xl font-semibold">
|
||||
<div className="min-h-screen px-4 pt-4 pb-16">
|
||||
<div className="mb-8 flex justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<p className="mb-6 flex items-center justify-center gap-2.5 font-serif text-3xl font-semibold">
|
||||
<span className="text-accent">
|
||||
<BookIcon />
|
||||
</span>
|
||||
My Library
|
||||
</p>
|
||||
<main className={`mx-auto max-w-sm ${card} sm:p-8`}>
|
||||
<h1 className="mb-5 font-serif text-3xl font-medium">
|
||||
{isLogin ? "Sign in" : "Create account"}
|
||||
</h1>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<label className="block">
|
||||
<span className={labelText}>Username</span>
|
||||
<input
|
||||
@@ -69,7 +87,8 @@ export function LoginForm() {
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p id="login-error" role="alert" className="text-sm text-red-700">
|
||||
<p id="login-error" role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -83,7 +102,7 @@ export function LoginForm() {
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 text-sm text-indigo-700 underline"
|
||||
className={`mt-5 min-h-11 ${linkText}`}
|
||||
onClick={() => {
|
||||
setMode(isLogin ? "register" : "login");
|
||||
setError(null);
|
||||
|
||||
+64
-1
@@ -1,9 +1,10 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
alice,
|
||||
dune,
|
||||
genres,
|
||||
held,
|
||||
mockApi,
|
||||
renderApp,
|
||||
signedIn,
|
||||
@@ -108,3 +109,65 @@ describe("auth flow", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial session check", () => {
|
||||
it("does not sign out a user who logged in while /auth/me was pending", async () => {
|
||||
const me = held();
|
||||
mockApi({
|
||||
"GET /api/auth/me": () => me.reply,
|
||||
"POST /api/auth/login": () => [200, alice],
|
||||
"GET /api/genres": () => [200, genres],
|
||||
"GET /api/books": () => [200, []],
|
||||
});
|
||||
const user = renderApp("/login");
|
||||
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" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Sign in" }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
me.release([
|
||||
401,
|
||||
{ error: "Not signed in or session expired; log in again" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
await screen.findByText("No books yet. Add your first one."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session expiry", () => {
|
||||
it("keeps a fresh login when an earlier session's request returns a late 401", async () => {
|
||||
const expiredGenres = held();
|
||||
let genreRequests = 0;
|
||||
let bookRequests = 0;
|
||||
mockApi({
|
||||
"GET /api/auth/me": () => [200, alice],
|
||||
"POST /api/auth/login": () => [200, alice],
|
||||
"GET /api/genres": () =>
|
||||
++genreRequests === 1 ? expiredGenres.reply : [200, genres],
|
||||
"GET /api/books": () =>
|
||||
++bookRequests === 1 ? [401, { error: "Session expired" }] : [200, []],
|
||||
});
|
||||
const user = renderApp("/books");
|
||||
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" }));
|
||||
await screen.findByText("No books yet. Add your first one.");
|
||||
|
||||
await act(async () => {
|
||||
expiredGenres.release([401, { error: "Previous session expired" }]);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Sign in" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Icon({ size = 18, children }: { size?: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const BookIcon = () => (
|
||||
<Icon size={26}>
|
||||
<path d="M12 6.5C10 5 7.5 4.5 4 4.5v13c3.5 0 6 .5 8 2 2-1.5 4.5-2 8-2v-13c-3.5 0-6 .5-8 2Z" />
|
||||
<path d="M12 6.5v13" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const PlusIcon = () => (
|
||||
<Icon>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const AlertIcon = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7.5v5.5M12 16.5v.01" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const TrashIcon = () => (
|
||||
<Icon>
|
||||
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<Icon>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4 4" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const NotStartedIcon = () => (
|
||||
<Icon size={14}>
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="3 3" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const ReadingIcon = () => (
|
||||
<Icon size={14}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const FinishedIcon = () => (
|
||||
<Icon size={14}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12.5 2.5 2.5L16 9.5" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const SystemThemeIcon = () => (
|
||||
<Icon>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const SunIcon = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const MoonIcon = () => (
|
||||
<Icon>
|
||||
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||
</Icon>
|
||||
);
|
||||
@@ -1 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: light-dark(#f7f3ec, #181512);
|
||||
--surface: light-dark(#fffdf8, #231f1b);
|
||||
--field: light-dark(#fffdf8, #1e1a17);
|
||||
--line: light-dark(#e4dccd, #3a332c);
|
||||
--control: light-dark(#8c7f6b, #7d7266);
|
||||
--ink: light-dark(#2b2620, #eee7dd);
|
||||
--muted: light-dark(#5e5548, #b9ae9f);
|
||||
--accent: light-dark(#2f5d50, #e3a857);
|
||||
--accent-hover: light-dark(#21443a, #edbb73);
|
||||
--on-accent: light-dark(#ffffff, #1a1612);
|
||||
--danger: light-dark(#a3341f, #f29c86);
|
||||
--track: light-dark(#e4dccd, #3a332c);
|
||||
--idle-bg: light-dark(#ece6db, #2e2924);
|
||||
--idle-fg: light-dark(#4a4237, #cfc5b8);
|
||||
--reading-bg: light-dark(#f6e6cf, #3d2a12);
|
||||
--reading-fg: light-dark(#7a4310, #f2c17e);
|
||||
--finished-bg: light-dark(#dce7f3, #1d3047);
|
||||
--finished-fg: light-dark(#234b78, #a9cdf2);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-control: var(--control);
|
||||
--color-ink: var(--ink);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-on-accent: var(--on-accent);
|
||||
--color-danger: var(--danger);
|
||||
--color-track: var(--track);
|
||||
--color-idle-bg: var(--idle-bg);
|
||||
--color-idle-fg: var(--idle-fg);
|
||||
--color-reading-bg: var(--reading-bg);
|
||||
--color-reading-fg: var(--reading-fg);
|
||||
--color-finished-bg: var(--finished-bg);
|
||||
--color-finished-fg: var(--finished-fg);
|
||||
--font-sans: "Atkinson Hyperlegible", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Newsreader Variable", Georgia, serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg font-sans text-ink;
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,9 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "@fontsource/atkinson-hyperlegible/400.css";
|
||||
import "@fontsource/atkinson-hyperlegible/700.css";
|
||||
import "@fontsource-variable/newsreader";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error(
|
||||
'index.html has no element with id="root", so the app has nowhere to mount',
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Note } from "../api";
|
||||
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||
import { dune, held, renderApp, signedIn } from "../test/helpers";
|
||||
|
||||
const older: Note = {
|
||||
id: 11,
|
||||
@@ -61,7 +61,8 @@ describe("NotesJournal", () => {
|
||||
],
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
const item = (await screen.findByText("Started reading")).closest("li")!;
|
||||
const item = (await screen.findByText("Started reading")).closest("li");
|
||||
if (!item) throw new Error("Note text is not rendered inside a list item");
|
||||
await user.click(within(item).getByRole("button", { name: "Edit entry" }));
|
||||
const editor = within(item).getByLabelText("Edit entry");
|
||||
await user.clear(editor);
|
||||
@@ -124,3 +125,71 @@ describe("NotesJournal", () => {
|
||||
expect(calls.filter((c) => c.method === "POST")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotesJournal request races", () => {
|
||||
it("does not allow adding an entry until the journal has loaded", async () => {
|
||||
const notes = held();
|
||||
const calls = signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => notes.reply,
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
await user.type(await screen.findByLabelText("New journal entry"), "Early");
|
||||
expect(screen.getByRole("button", { name: "Add entry" })).toBeDisabled();
|
||||
|
||||
notes.release([200, [older]]);
|
||||
|
||||
await screen.findByText("Started reading");
|
||||
expect(screen.getByRole("button", { name: "Add entry" })).toBeEnabled();
|
||||
expect(calls.some((c) => c.method === "POST")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let typing during a save be lost when the save completes", async () => {
|
||||
const saved = held();
|
||||
signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => [200, []],
|
||||
"POST /api/books/7/notes": () => saved.reply,
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
const draft = await screen.findByLabelText("New journal entry");
|
||||
await screen.findByText("No entries yet.");
|
||||
await user.type(draft, "First thought");
|
||||
await user.click(screen.getByRole("button", { name: "Add entry" }));
|
||||
await user.type(draft, " and more");
|
||||
expect(draft).toHaveValue("First thought");
|
||||
|
||||
saved.release([201, { ...older, id: 12, body: "First thought" }]);
|
||||
|
||||
expect(await screen.findByText("First thought")).toBeInTheDocument();
|
||||
expect(draft).toHaveValue("");
|
||||
});
|
||||
|
||||
it("prevents overlapping saves of an existing entry", async () => {
|
||||
const saved = held();
|
||||
signedIn({
|
||||
"GET /api/books/7": () => [200, dune],
|
||||
"GET /api/books/7/notes": () => [200, [older]],
|
||||
"PATCH /api/notes/11": () => saved.reply,
|
||||
});
|
||||
const user = renderApp("/books/7");
|
||||
await user.click(await screen.findByRole("button", { name: "Edit entry" }));
|
||||
const editor = screen.getByLabelText("Edit entry");
|
||||
await user.clear(editor);
|
||||
await user.type(editor, "First edit");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(save);
|
||||
|
||||
try {
|
||||
expect(save).toBeDisabled();
|
||||
await user.type(editor, " and more");
|
||||
expect(editor).toHaveValue("First edit");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
saved.release([200, { ...older, body: "First edit" }]);
|
||||
});
|
||||
}
|
||||
|
||||
expect(await screen.findByText("First edit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+134
-73
@@ -1,55 +1,66 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { api, errorMessage, type Note } from '../api'
|
||||
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
||||
import { type SubmitEvent, useEffect, useState } from "react";
|
||||
import { api, errorMessage, type Note } from "../api";
|
||||
import { AlertIcon, TrashIcon } from "../icons";
|
||||
import {
|
||||
card,
|
||||
dangerButton,
|
||||
errorText,
|
||||
inputClass,
|
||||
labelText,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
} from "../ui";
|
||||
|
||||
const MAX_NOTE = 10000
|
||||
const MAX_NOTE = 10000;
|
||||
|
||||
export function NotesJournal({ bookId }: { bookId: number }) {
|
||||
const [notes, setNotes] = useState<Note[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [addError, setAddError] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [notes, setNotes] = useState<Note[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoadError(null)
|
||||
api<Note[]>('GET', `/books/${bookId}/notes`).then(
|
||||
let cancelled = false;
|
||||
setLoadError(null);
|
||||
api<Note[]>("GET", `/books/${bookId}/notes`).then(
|
||||
(found) => {
|
||||
if (!cancelled) setNotes(found)
|
||||
if (!cancelled) setNotes(found);
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
if (!cancelled) setLoadError(errorMessage(err));
|
||||
},
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bookId, attempt])
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bookId, attempt]);
|
||||
|
||||
async function addNote(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (adding) return
|
||||
setAdding(true)
|
||||
setAddError(null)
|
||||
async function addNote(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (adding || notes === null) return;
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const note = await api<Note>('POST', `/books/${bookId}/notes`, { body: draft })
|
||||
setNotes((prev) => [note, ...(prev ?? [])])
|
||||
setDraft('')
|
||||
const note = await api<Note>("POST", `/books/${bookId}/notes`, {
|
||||
body: draft,
|
||||
});
|
||||
setNotes((prev) => [note, ...(prev ?? [])]);
|
||||
setDraft("");
|
||||
} catch (err) {
|
||||
setAddError(errorMessage(err))
|
||||
setAddError(errorMessage(err));
|
||||
} finally {
|
||||
setAdding(false)
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="journal-heading" className={card}>
|
||||
<h2 id="journal-heading" className="mb-3 text-lg font-semibold">
|
||||
<section aria-labelledby="journal-heading" className="space-y-4">
|
||||
<h2 id="journal-heading" className="font-serif text-3xl font-medium">
|
||||
Reading journal
|
||||
</h2>
|
||||
<form onSubmit={addNote} className="space-y-2">
|
||||
<form onSubmit={addNote} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className={labelText}>New journal entry</span>
|
||||
<textarea
|
||||
@@ -58,81 +69,118 @@ export function NotesJournal({ bookId }: { bookId: number }) {
|
||||
maxLength={MAX_NOTE}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
readOnly={adding}
|
||||
required
|
||||
aria-describedby={addError ? 'add-note-error' : undefined}
|
||||
aria-describedby={addError ? "add-note-error" : undefined}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={adding} className={primaryButton}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || notes === null}
|
||||
className={primaryButton}
|
||||
>
|
||||
Add entry
|
||||
</button>
|
||||
{addError && (
|
||||
<p id="add-note-error" role="alert" className="text-sm text-red-700">
|
||||
<p id="add-note-error" role="alert" className={errorText}>
|
||||
<AlertIcon />
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{loadError ? (
|
||||
<div role="alert" className="mt-4 flex items-center gap-3 text-red-700">
|
||||
<span>{loadError}</span>
|
||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||
<span className={errorText}>
|
||||
<AlertIcon />
|
||||
{loadError}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
onClick={() => setAttempt((n) => n + 1)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : notes === null ? (
|
||||
<p className="mt-4 text-slate-500">Loading…</p>
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="mt-4 text-slate-600">No entries yet.</p>
|
||||
<p className="text-muted">No entries yet.</p>
|
||||
) : (
|
||||
<ol className="mt-4">
|
||||
<ol className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<NoteItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onSaved={(saved) => setNotes((prev) => prev!.map((n) => (n.id === saved.id ? saved : n)))}
|
||||
onDeleted={() => setNotes((prev) => prev!.filter((n) => n.id !== note.id))}
|
||||
onSaved={(saved) =>
|
||||
setNotes(
|
||||
(prev) =>
|
||||
prev?.map((n) => (n.id === saved.id ? saved : n)) ?? null,
|
||||
)
|
||||
}
|
||||
onDeleted={() =>
|
||||
setNotes(
|
||||
(prev) => prev?.filter((n) => n.id !== note.id) ?? null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: Note) => void; onDeleted: () => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState(note.body)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const errorId = `note-${note.id}-error`
|
||||
function NoteItem({
|
||||
note,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
note: Note;
|
||||
onSaved: (note: Note) => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(note.body);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const errorId = `note-${note.id}-error`;
|
||||
|
||||
async function save(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
onSaved(await api<Note>('PATCH', `/notes/${note.id}`, { body: text }))
|
||||
setEditing(false)
|
||||
onSaved(await api<Note>("PATCH", `/notes/${note.id}`, { body: text }));
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setError(null)
|
||||
setError(null);
|
||||
try {
|
||||
await api('DELETE', `/notes/${note.id}`)
|
||||
onDeleted()
|
||||
await api("DELETE", `/notes/${note.id}`);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="border-t border-slate-200 py-3">
|
||||
<p className="text-xs text-slate-500">
|
||||
<time dateTime={note.created_at}>{new Date(note.created_at).toLocaleString()}</time>
|
||||
{note.updated_at !== note.created_at && ' · edited'}
|
||||
<li className={`${card} px-6 py-5`}>
|
||||
<p className="text-sm text-muted">
|
||||
<time dateTime={note.created_at}>
|
||||
{new Date(note.created_at).toLocaleString()}
|
||||
</time>
|
||||
{note.updated_at !== note.created_at && " · edited"}
|
||||
</p>
|
||||
{editing ? (
|
||||
<form onSubmit={save} className="mt-1 space-y-2">
|
||||
<form onSubmit={save} className="mt-2 space-y-3">
|
||||
<label className="block">
|
||||
<span className="sr-only">Edit entry</span>
|
||||
<textarea
|
||||
@@ -141,20 +189,21 @@ function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: No
|
||||
maxLength={MAX_NOTE}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={saving}
|
||||
required
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className={primaryButton}>
|
||||
<button type="submit" disabled={saving} className={primaryButton}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
onClick={() => {
|
||||
setEditing(false)
|
||||
setText(note.body)
|
||||
setEditing(false);
|
||||
setText(note.body);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -163,22 +212,34 @@ function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: No
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 whitespace-pre-wrap">{note.body}</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" className={secondaryButton} onClick={() => setEditing(true)}>
|
||||
<p className="mt-2 font-serif text-lg leading-relaxed whitespace-pre-wrap">
|
||||
{note.body}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Edit entry
|
||||
</button>
|
||||
<button type="button" className={dangerButton} onClick={() => void remove()}>
|
||||
<button
|
||||
type="button"
|
||||
className={dangerButton}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete entry
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="mt-2 text-sm text-red-700">
|
||||
<p id={errorId} role="alert" className={`mt-3 ${errorText}`}>
|
||||
<AlertIcon />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+11
-3
@@ -2,8 +2,8 @@ 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 type { Book, Genre, User } from "../api";
|
||||
import { AuthProvider } from "../auth/AuthContext";
|
||||
|
||||
export const alice: User = { id: 1, username: "alice" };
|
||||
@@ -24,9 +24,17 @@ export const dune: Book = {
|
||||
};
|
||||
|
||||
type Reply = [status: number, body?: unknown];
|
||||
type Handler = (request: { body: unknown; url: URL }) => Reply;
|
||||
type Handler = (request: { body: unknown; url: URL }) => Reply | Promise<Reply>;
|
||||
export type Call = { method: string; path: string; body: unknown };
|
||||
|
||||
export function held() {
|
||||
let release!: (reply: Reply) => void;
|
||||
const reply = new Promise<Reply>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { reply, release };
|
||||
}
|
||||
|
||||
export function mockApi(routes: Record<string, Handler>): Call[] {
|
||||
const calls: Call[] = [];
|
||||
vi.stubGlobal(
|
||||
@@ -39,7 +47,7 @@ export function mockApi(routes: Record<string, Handler>): Call[] {
|
||||
calls.push({ method, path: url.pathname + url.search, body });
|
||||
const handler = routes[`${method} ${url.pathname}`];
|
||||
const [status, data] = handler
|
||||
? handler({ body, url })
|
||||
? await handler({ body, url })
|
||||
: [500, { error: `No mock for ${method} ${url.pathname}` }];
|
||||
return new Response(status === 204 ? null : JSON.stringify(data), {
|
||||
status,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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 card = "rounded-xl border border-line bg-surface p-6";
|
||||
export const pageTitle = "font-serif text-4xl font-medium tracking-tight";
|
||||
export const labelText = "mb-1.5 block text-sm font-bold";
|
||||
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";
|
||||
"block min-h-11 w-full rounded-lg border border-control bg-field px-3.5 py-2 text-ink placeholder:text-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent aria-invalid:border-2 aria-invalid:border-danger";
|
||||
export const linkText =
|
||||
"font-bold text-accent underline underline-offset-3 hover:text-accent-hover";
|
||||
export const errorText =
|
||||
"flex items-center gap-2 text-sm font-bold text-danger";
|
||||
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`;
|
||||
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-5 font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50";
|
||||
export const primaryButton = `${button} bg-accent text-on-accent hover:bg-accent-hover`;
|
||||
export const secondaryButton = `${button} border border-control text-ink hover:bg-line`;
|
||||
export const dangerButton = `${button} border border-danger text-danger hover:bg-danger/10`;
|
||||
|
||||
Reference in New Issue
Block a user