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
+25 -1
View File
@@ -1,6 +1,30 @@
# Python
.venv/ .venv/
__pycache__/ __pycache__/
*.py[cod]
*.egg-info/
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
htmlcov/
# Node
node_modules/ node_modules/
dist/ dist/
backend/.pepper coverage/
*.tsbuildinfo
npm-debug.log*
# Secrets
.env .env
.env.*
!.env.example
backend/.pepper
# Editors and OS
.vscode/
.idea/
.DS_Store
*.swp
+54 -41
View File
@@ -1,58 +1,71 @@
# My Library: Personal Book Collection Tracker # My Library
Flask + React + PostgreSQL. Users catalog their books, track reading progress, keep a reading journal per A personal book tracker. You sign up, add the books you own, track how far you are in each one, and keep a
book, and search/filter their library. Each user sees only their own data. reading journal for every book. You can search your library by title or author and filter it by genre. Each
user sees only their own books.
## Run (CoderPad) It is a Flask (Python) API with a React (TypeScript) frontend, backed by PostgreSQL.
The pad starts Flask (`backend/app.py`, port 5000) and Vite automatically. The schema is created on startup. ## What you need
Optionally set `PASSWORD_PEPPER` (≥ 32 chars). Without it, a development pepper is generated into
`backend/.pepper` and a warning is logged.
## Run (local) - [mise](https://mise.jdx.dev) – installs the right versions of Python, Node and the other tools for you
- [Docker](https://www.docker.com) – runs the PostgreSQL database
## First-time setup
```bash ```bash
mise trust && mise install # Python 3.14, Node 24 mise trust # allow mise to use this project's mise.toml
docker compose up -d db # postgres:16 on 127.0.0.1:5432 mise install # install Python, Node, uv, ruff and biome
until docker compose exec -T db pg_isready -U postgres; do sleep 1; done mise run setup # create the Python virtual environment and install all dependencies
mise exec -- python -m venv .venv && .venv/bin/pip install -r backend/requirements.txt
mise exec -- npm install
.venv/bin/python backend/app.py # API on :5000
mise exec -- npm run dev # UI; /api is proxied to Flask
``` ```
## Test ## Run the app
```bash ```bash
.venv/bin/python -W error -m unittest discover -s backend/tests -t backend -v # real Postgres (books_test); run from repo root mise run dev
mise exec -- npm test && mise exec -- npm run typecheck
``` ```
## Design This starts the database, the API (port 5000) and the web app. Open http://localhost:5173 and create an
account. Press `Ctrl+C` to stop it.
- **Feature slices.** One Flask Blueprint per feature: `auth.py`, `books.py` and `notes.py` each hold their You can also start the parts on their own: `mise run db`, `mise run dev:api` and `mise run dev:web`.
routes, validation and SQL. `db.py` holds the pool and transactions; `validation.py` holds the shared input rules.
- **Isolation in SQL.** Every query filters on the session's user (`AND user_id = %s`, or joins to `books`
for notes). Another user's book and a missing book return the same 404.
- **Passwords.** Peppered PBKDF2-HMAC-SHA256 (FIPS-approved, stdlib only). See [ADR-0001](docs/adr/0001-password-hashing-pbkdf2.md).
- **Sessions.** Opaque tokens hashed at rest; 30-minute idle and 12-hour absolute expiry; real logout; CSRF
handled by SameSite plus a JSON-only rule. See [ADR-0002](docs/adr/0002-server-side-sessions.md).
- **Data rules in two places.** The API returns specific 400s, and Postgres CHECK and FK constraints
backstop them.
- **Frontend.** React Router (search and filter state lives in the URL), a small auth context, Tailwind.
No state library.
Full spec: [docs/superpowers/specs/2026-10-02-book-tracker-design.md](docs/superpowers/specs/2026-10-02-book-tracker-design.md) To stop the database when you are done, run `docker compose down`.
## Known trade-offs ## Run the tests
| Not included | Add when | ```bash
|---|---| mise run test # everything
| Pagination | libraries exceed a few hundred books | mise run test:py # backend only
| Trigram/full-text index | ILIKE search slows (thousands of books) | mise run test:ts # frontend only, plus the type check
| Login rate limiting | before production | ```
| Migrations tool | schema changes after first deploy |
| Pepper rotation | before production key management |
A request that fails with 4xx rolls back its transaction, including the session idle-window slide, so failed The backend tests use a real PostgreSQL database called `books_test`. It is created for you, so the database
requests don't extend the 30-minute idle timeout. from `mise run db` just needs to be running (the test task starts it).
## Lint and format
```bash
mise run format # fix formatting and import order (writes changes)
mise run lint # check linting, formatting and types without changing files
```
Run `mise run lint` and `mise run test` before you push.
## Change backend dependencies
Edit `backend/requirements.in`, then run:
```bash
mise run lock # rebuild the pinned backend/requirements.txt
mise run setup # install the new versions
```
## Settings
- `DATABASE_URL` – the PostgreSQL connection string. It defaults to the local Docker database.
- `PASSWORD_PEPPER` – a secret of at least 32 characters used when storing passwords. If you do not set it,
a development value is created in `backend/.pepper` and a warning is logged. Always set it outside local
development.
Run `mise tasks` to see every available task.
+14 -12
View File
@@ -3,7 +3,8 @@
import logging import logging
import psycopg2.errors import psycopg2.errors
from flask import Flask, g, jsonify, request from flask import Flask, Response, g, jsonify, request
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
import auth import auth
@@ -28,18 +29,19 @@ def create_app() -> Flask:
app.register_blueprint(notes.bp) app.register_blueprint(notes.bp)
@app.get("/api/health") @app.get("/api/health")
def health(): @auth.allow_anonymous
def health() -> ResponseReturnValue:
return {"status": "ok"} return {"status": "ok"}
@app.before_request @app.before_request
def require_json_for_mutations(): def require_json_for_mutations() -> None:
if request.method in MUTATING_METHODS and not request.is_json: if request.method in MUTATING_METHODS and not request.is_json:
raise ApiError( raise ApiError(
400, "Request body must be JSON with Content-Type: application/json" 400, "Request body must be JSON with Content-Type: application/json"
) )
@app.after_request @app.after_request
def log_request(response): def log_request(response: Response) -> Response:
log.info( log.info(
"%s %s -> %s user=%s", "%s %s -> %s user=%s",
request.method, request.method,
@@ -50,18 +52,20 @@ def create_app() -> Flask:
return response return response
@app.errorhandler(ApiError) @app.errorhandler(ApiError)
def handle_api_error(error: ApiError): def handle_api_error(error: ApiError) -> ResponseReturnValue:
body = {"error": error.message} body = {"error": error.message}
if error.field: if error.field:
body["field"] = error.field body["field"] = error.field
return jsonify(body), error.status return jsonify(body), error.status
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def handle_http_error(error: HTTPException): def handle_http_error(error: HTTPException) -> ResponseReturnValue:
return jsonify(error=error.description), error.code return jsonify(error=error.description), error.code or 500
@app.errorhandler(psycopg2.errors.CheckViolation) @app.errorhandler(psycopg2.errors.CheckViolation)
def handle_check_violation(error: psycopg2.errors.CheckViolation): def handle_check_violation(
error: psycopg2.errors.CheckViolation,
) -> ResponseReturnValue:
return ( return (
jsonify( jsonify(
error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry" error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"
@@ -70,14 +74,12 @@ def create_app() -> Flask:
) )
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_unexpected(_error: Exception): def handle_unexpected(_error: Exception) -> ResponseReturnValue:
log.exception("Unhandled error on %s %s", request.method, request.path) log.exception("Unhandled error on %s %s", request.method, request.path)
return jsonify(error="Unexpected server error"), 500 return jsonify(error="Unexpected server error"), 500
return app return app
app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000) create_app().run(host="127.0.0.1", port=5000)
+63 -102
View File
@@ -1,115 +1,73 @@
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes.""" """Auth slice: server-side sessions (ADR-0002) and auth routes."""
import base64
import functools
import hashlib import hashlib
import hmac
import logging
import os
import re import re
import secrets import secrets
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import ParamSpec, TypedDict, TypeVar, cast
import psycopg2.errors import psycopg2.errors
from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request from flask import (
Blueprint,
Flask,
Response,
current_app,
g,
jsonify,
make_response,
request,
)
from db import query, query_one from db import query, query_one, query_row
from validation import ApiError, json_body, require_utf8 from passwords import (
dummy_hash,
hash_password,
load_pepper,
needs_rehash,
verify_password,
)
from validation import ApiError, JsonObject, json_body, require_utf8
log = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/api/auth") bp = Blueprint("auth", __name__, url_prefix="/api/auth")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
SESSION_COOKIE = "sid" SESSION_COOKIE = "sid"
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}") USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
MIN_PASSWORD, MAX_PASSWORD = 12, 1024 MIN_PASSWORD, MAX_PASSWORD = 12, 1024
INVALID_LOGIN = "Invalid username or password" INVALID_LOGIN = "Invalid username or password"
P = ParamSpec("P")
R = TypeVar("R")
class UserRow(TypedDict):
id: int
username: str
class UserWithHash(UserRow):
password_hash: str
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper") app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash() dummy_hash()
app.before_request(_require_session)
app.register_blueprint(bp) app.register_blueprint(bp)
# --- Passwords -----------------------------------------------------------------
def hash_password(
password: str,
pepper: bytes,
*,
salt: bytes | None = None,
iterations: int = ITERATIONS,
) -> str:
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
derived = _derive(password, pepper, salt, iterations)
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
_, iterations, salt, expected = stored.split("$")
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
return hmac.compare_digest(derived, base64.b64decode(expected))
def needs_rehash(stored: str) -> bool:
return int(stored.split("$")[1]) < ITERATIONS
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
@functools.cache
def _dummy_hash() -> str:
# Verified against for unknown usernames so their response time matches a real account.
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
def load_pepper(pepper_file: Path) -> bytes:
from_env = os.environ.get("PASSWORD_PEPPER")
if from_env is not None:
if len(from_env) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return from_env.encode()
try:
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(fd, "w") as f:
f.write(secrets.token_hex(32))
log.warning(
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
"from a secrets manager.",
pepper_file,
)
pepper = pepper_file.read_text().strip()
if len(pepper) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
"the file to regenerate it (existing passwords will stop verifying)."
)
return pepper.encode()
# --- Sessions ------------------------------------------------------------------ # --- Sessions ------------------------------------------------------------------
def login_required(view): def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(view) view.allow_anonymous = True # type: ignore[attr-defined]
def wrapper(*args, **kwargs): return view
def _require_session() -> None:
view = current_app.view_functions.get(request.endpoint or "")
if view is None or getattr(view, "allow_anonymous", False):
return
token = request.cookies.get(SESSION_COOKIE) token = request.cookies.get(SESSION_COOKIE)
row = None row = None
if token: if token:
@@ -127,9 +85,6 @@ def login_required(view):
if row is None: if row is None:
raise ApiError(401, "Not signed in or session expired; log in again") raise ApiError(401, "Not signed in or session expired; log in again")
g.user_id = row["user_id"] g.user_id = row["user_id"]
return view(*args, **kwargs)
return wrapper
def _token_hash(token: str) -> bytes: def _token_hash(token: str) -> bytes:
@@ -137,10 +92,11 @@ def _token_hash(token: str) -> bytes:
def _pepper() -> bytes: def _pepper() -> bytes:
return current_app.extensions["password_pepper"] pepper: bytes = current_app.extensions["password_pepper"]
return pepper
def _start_session(user: dict, status: int): def _start_session(user: UserRow, status: int) -> Response:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
query( query(
"DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],) "DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],)
@@ -160,7 +116,7 @@ def _start_session(user: dict, status: int):
# --- Routes ---------------------------------------------------------------------- # --- Routes ----------------------------------------------------------------------
def _password(body: dict) -> str: def _password(body: JsonObject) -> str:
password = body.get("password") password = body.get("password")
if not isinstance(password, str): if not isinstance(password, str):
raise ApiError(400, "password is required and must be a string", "password") raise ApiError(400, "password is required and must be a string", "password")
@@ -173,7 +129,8 @@ def _password(body: dict) -> str:
@bp.post("/register") @bp.post("/register")
def register(): @allow_anonymous
def register() -> Response:
body = json_body({"username", "password"}) body = json_body({"username", "password"})
username = body.get("username") username = body.get("username")
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username): if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
@@ -188,7 +145,7 @@ def register():
400, f"password must be at least {MIN_PASSWORD} characters", "password" 400, f"password must be at least {MIN_PASSWORD} characters", "password"
) )
try: try:
user = query_one( user = query_row(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
(username, hash_password(password, _pepper())), (username, hash_password(password, _pepper())),
) )
@@ -196,22 +153,26 @@ def register():
raise ApiError( raise ApiError(
409, "Username already taken; choose another", "username" 409, "Username already taken; choose another", "username"
) from None ) from None
return _start_session(user, 201) return _start_session(cast(UserRow, user), 201)
@bp.post("/login") @bp.post("/login")
def login(): @allow_anonymous
def login() -> Response:
body = json_body({"username", "password"}) body = json_body({"username", "password"})
password = _password(body) password = _password(body)
username = body.get("username") username = body.get("username")
user = None user = None
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username): if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
user = query_one( user = cast(
UserWithHash | None,
query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", "SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,), (username,),
),
) )
if user is None: if user is None:
verify_password(password, _dummy_hash(), _pepper()) verify_password(password, dummy_hash(), _pepper())
raise ApiError(401, INVALID_LOGIN) raise ApiError(401, INVALID_LOGIN)
if not verify_password(password, user["password_hash"], _pepper()): if not verify_password(password, user["password_hash"], _pepper()):
raise ApiError(401, INVALID_LOGIN) raise ApiError(401, INVALID_LOGIN)
@@ -224,7 +185,8 @@ def login():
@bp.post("/logout") @bp.post("/logout")
def logout(): @allow_anonymous
def logout() -> Response:
token = request.cookies.get(SESSION_COOKIE) token = request.cookies.get(SESSION_COOKIE)
if token: if token:
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),)) query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
@@ -236,8 +198,7 @@ def logout():
@bp.get("/me") @bp.get("/me")
@login_required def me() -> Response:
def me():
return jsonify( return jsonify(
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)) query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
) )
+56 -32
View File
@@ -1,13 +1,16 @@
"""Books slice: CRUD, progress, search/filter, and the genre list.""" """Books slice: CRUD, progress, search/filter, and the genre list."""
import re import re
from collections.abc import Callable
from datetime import datetime
from typing import TypedDict, cast
import psycopg2.errors import psycopg2.errors
from flask import Blueprint, g, jsonify, request from flask import Blueprint, Response, g, jsonify, request
from flask.typing import ResponseReturnValue
from auth import login_required from db import Params, Row, query, query_one, query_row
from db import query, query_one from validation import ApiError, JsonObject, integer, json_body, text
from validation import ApiError, integer, json_body, text
bp = Blueprint("books", __name__, url_prefix="/api") bp = Blueprint("books", __name__, url_prefix="/api")
@@ -17,7 +20,7 @@ MAX_GENRE_ID = 32_767
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}") GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages") REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
FIELDS = set(REQUIRED_FIELDS) | {"current_page"} FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
VALIDATORS = { VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = {
"title": lambda body: text(body, "title", MAX_TEXT), "title": lambda body: text(body, "title", MAX_TEXT),
"author": lambda body: text(body, "author", MAX_TEXT), "author": lambda body: text(body, "author", MAX_TEXT),
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID), "genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
@@ -25,6 +28,22 @@ VALIDATORS = {
"current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES), "current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES),
} }
class BookFields(TypedDict):
title: str
author: str
genre_id: int
total_pages: int
current_page: int
class BookRow(BookFields):
id: int
genre_name: str
created_at: datetime
updated_at: datetime
BOOK_SELECT = """ BOOK_SELECT = """
SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name, SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name,
b.total_pages, b.current_page, b.created_at, b.updated_at b.total_pages, b.current_page, b.created_at, b.updated_at
@@ -41,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str:
return "reading" return "reading"
def to_json(row: dict) -> dict: def to_json(row: BookRow) -> dict[str, object]:
return { return {
"id": row["id"], "id": row["id"],
"title": row["title"], "title": row["title"],
@@ -59,16 +78,19 @@ def escape_like(term: str) -> str:
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def owned_book(book_id: int) -> dict: def owned_book(book_id: int, for_update: bool = False) -> BookRow:
row = query_one( row = query_one(
BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id) BOOK_SELECT
+ " WHERE b.id = %s AND b.user_id = %s"
+ (" FOR UPDATE OF b" if for_update else ""),
(book_id, g.user_id),
) )
if row is None: if row is None:
raise ApiError(404, "Book not found") raise ApiError(404, "Book not found")
return row return cast(BookRow, row)
def _check_progress(book: dict, field: str) -> None: def _check_progress(book: BookFields, field: str) -> None:
if book["current_page"] > book["total_pages"]: if book["current_page"] > book["total_pages"]:
raise ApiError( raise ApiError(
400, 400,
@@ -78,9 +100,9 @@ def _check_progress(book: dict, field: str) -> None:
) )
def _save(sql: str, params: tuple) -> dict | None: def _save(sql: str, params: Params) -> Row:
try: try:
return query_one(sql, params) return query_row(sql, params)
except psycopg2.errors.ForeignKeyViolation: except psycopg2.errors.ForeignKeyViolation:
raise ApiError( raise ApiError(
400, "Unknown genre_id; see GET /api/genres", "genre_id" 400, "Unknown genre_id; see GET /api/genres", "genre_id"
@@ -88,16 +110,14 @@ def _save(sql: str, params: tuple) -> dict | None:
@bp.get("/genres") @bp.get("/genres")
@login_required def list_genres() -> Response:
def list_genres():
return jsonify(query("SELECT id, name FROM genres ORDER BY name")) return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
@bp.get("/books") @bp.get("/books")
@login_required def list_books() -> Response:
def list_books():
sql = BOOK_SELECT + " WHERE b.user_id = %s" sql = BOOK_SELECT + " WHERE b.user_id = %s"
params: list = [g.user_id] params: list[object] = [g.user_id]
search = request.args.get("q", "").strip() search = request.args.get("q", "").strip()
if search: if search:
if len(search) > MAX_TEXT or "\x00" in search: if len(search) > MAX_TEXT or "\x00" in search:
@@ -116,17 +136,21 @@ def list_books():
sql += " AND b.genre_id = %s" sql += " AND b.genre_id = %s"
params.append(int(genre_id)) params.append(int(genre_id))
sql += " ORDER BY b.updated_at DESC, b.id DESC" sql += " ORDER BY b.updated_at DESC, b.id DESC"
return jsonify([to_json(row) for row in query(sql, tuple(params))]) return jsonify([to_json(cast(BookRow, row)) for row in query(sql, tuple(params))])
@bp.post("/books") @bp.post("/books")
@login_required def create_book() -> ResponseReturnValue:
def create_book():
body = json_body(FIELDS) body = json_body(FIELDS)
book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS} book = cast(
book["current_page"] = ( BookFields,
{field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
| {
"current_page": (
VALIDATORS["current_page"](body) if "current_page" in body else 0 VALIDATORS["current_page"](body) if "current_page" in body else 0
) )
},
)
_check_progress(book, "current_page") _check_progress(book, "current_page")
row = _save( row = _save(
""" """
@@ -147,27 +171,28 @@ def create_book():
@bp.get("/books/<int(max=2147483647):book_id>") @bp.get("/books/<int(max=2147483647):book_id>")
@login_required def get_book(book_id: int) -> Response:
def get_book(book_id: int):
return jsonify(to_json(owned_book(book_id))) return jsonify(to_json(owned_book(book_id)))
@bp.patch("/books/<int(max=2147483647):book_id>") @bp.patch("/books/<int(max=2147483647):book_id>")
@login_required def update_book(book_id: int) -> Response:
def update_book(book_id: int):
body = json_body(FIELDS) body = json_body(FIELDS)
if not body: if not body:
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}") raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
current = owned_book(book_id) current: Row = dict(owned_book(book_id, for_update=True))
book = {field: current[field] for field in FIELDS} | { book = cast(
field: VALIDATORS[field](body) for field in body BookFields,
} {field: current[field] for field in FIELDS}
| {field: VALIDATORS[field](body) for field in body},
)
_check_progress(book, "total_pages" if "total_pages" in body else "current_page") _check_progress(book, "total_pages" if "total_pages" in body else "current_page")
_save( _save(
""" """
UPDATE books UPDATE books
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now() SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
WHERE id = %s AND user_id = %s WHERE id = %s AND user_id = %s
RETURNING id
""", """,
( (
book["title"], book["title"],
@@ -183,8 +208,7 @@ def update_book(book_id: int):
@bp.delete("/books/<int(max=2147483647):book_id>") @bp.delete("/books/<int(max=2147483647):book_id>")
@login_required def delete_book(book_id: int) -> ResponseReturnValue:
def delete_book(book_id: int):
if ( if (
query_one( query_one(
"DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id", "DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id",
+39 -10
View File
@@ -1,15 +1,24 @@
"""PostgreSQL access: a connection pool, one connection per request, and two query helpers.""" """PostgreSQL access: a connection pool, one connection per request, and query helpers."""
import logging
import os import os
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import Any
import psycopg2.extensions
import psycopg2.extras import psycopg2.extras
import psycopg2.pool import psycopg2.pool
from flask import Flask, current_app, g from flask import Flask, Response, current_app, g
log = logging.getLogger(__name__)
DEFAULT_DATABASE_URL = "postgresql://postgres:[email protected]:5432/postgres" DEFAULT_DATABASE_URL = "postgresql://postgres:[email protected]:5432/postgres"
SCHEMA = Path(__file__).with_name("schema.sql") SCHEMA = Path(__file__).with_name("schema.sql")
Row = dict[str, Any]
Params = tuple[object, ...]
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
pool = psycopg2.pool.ThreadedConnectionPool( pool = psycopg2.pool.ThreadedConnectionPool(
@@ -26,24 +35,34 @@ def init_app(app: Flask) -> None:
app.teardown_appcontext(_release_connection) app.teardown_appcontext(_release_connection)
def query(sql: str, params: tuple = ()) -> list[dict]: def query(sql: str, params: Params = ()) -> Sequence[Row]:
with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params) cur.execute(sql, params)
return cur.fetchall() if cur.description else [] return cur.fetchall() if cur.description else []
def query_one(sql: str, params: tuple = ()) -> dict | None: def query_one(sql: str, params: Params = ()) -> Row | None:
rows = query(sql, params) rows = query(sql, params)
return rows[0] if rows else None return rows[0] if rows else None
def _connection(): def query_row(sql: str, params: Params = ()) -> Row:
rows = query(sql, params)
if len(rows) != 1:
raise RuntimeError(
f"Expected exactly one row but got {len(rows)}; the statement must return one row: {sql.strip()}"
)
return rows[0]
def _connection() -> psycopg2.extensions.connection:
if "db" not in g: if "db" not in g:
g.db = current_app.extensions["db_pool"].getconn() g.db = current_app.extensions["db_pool"].getconn()
return g.db conn: psycopg2.extensions.connection = g.db
return conn
def _finish_transaction(response): def _finish_transaction(response: Response) -> Response:
conn = g.get("db") conn = g.get("db")
if conn is not None: if conn is not None:
if response.status_code < 400: if response.status_code < 400:
@@ -53,8 +72,18 @@ def _finish_transaction(response):
return response return response
def _release_connection(_exc): def _release_connection(_exc: BaseException | None) -> None:
conn = g.pop("db", None) conn = g.pop("db", None)
if conn is not None: if conn is None:
return
pool = current_app.extensions["db_pool"]
try:
conn.rollback() conn.rollback()
current_app.extensions["db_pool"].putconn(conn) except psycopg2.Error:
log.warning(
"Discarding a database connection that failed to roll back; the pool will open a new one",
exc_info=True,
)
pool.putconn(conn, close=True)
else:
pool.putconn(conn)
+26 -18
View File
@@ -1,9 +1,12 @@
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id.""" """Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
from flask import Blueprint, g, jsonify from datetime import datetime
from typing import TypedDict, cast
from auth import login_required from flask import Blueprint, Response, g, jsonify
from db import query, query_one from flask.typing import ResponseReturnValue
from db import Row, query, query_one, query_row
from validation import ApiError, json_body, text from validation import ApiError, json_body, text
bp = Blueprint("notes", __name__, url_prefix="/api") bp = Blueprint("notes", __name__, url_prefix="/api")
@@ -12,13 +15,22 @@ MAX_BODY = 10_000
NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at" NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at"
def to_json(row: dict) -> dict: class NoteRow(TypedDict):
id: int
book_id: int
body: str
created_at: datetime
updated_at: datetime
def to_json(row: Row) -> dict[str, object]:
note = cast(NoteRow, row)
return { return {
"id": row["id"], "id": note["id"],
"book_id": row["book_id"], "book_id": note["book_id"],
"body": row["body"], "body": note["body"],
"created_at": row["created_at"].isoformat(), "created_at": note["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(), "updated_at": note["updated_at"].isoformat(),
} }
@@ -33,8 +45,7 @@ def _require_book(book_id: int) -> None:
@bp.get("/books/<int(max=2147483647):book_id>/notes") @bp.get("/books/<int(max=2147483647):book_id>/notes")
@login_required def list_notes(book_id: int) -> Response:
def list_notes(book_id: int):
_require_book(book_id) _require_book(book_id)
rows = query( rows = query(
f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC", f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC",
@@ -44,11 +55,10 @@ def list_notes(book_id: int):
@bp.post("/books/<int(max=2147483647):book_id>/notes") @bp.post("/books/<int(max=2147483647):book_id>/notes")
@login_required def add_note(book_id: int) -> ResponseReturnValue:
def add_note(book_id: int):
_require_book(book_id) _require_book(book_id)
body = text(json_body({"body"}), "body", MAX_BODY) body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one( row = query_row(
f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}", f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}",
(book_id, body), (book_id, body),
) )
@@ -56,8 +66,7 @@ def add_note(book_id: int):
@bp.patch("/notes/<int(max=2147483647):note_id>") @bp.patch("/notes/<int(max=2147483647):note_id>")
@login_required def update_note(note_id: int) -> Response:
def update_note(note_id: int):
body = text(json_body({"body"}), "body", MAX_BODY) body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one( row = query_one(
f""" f"""
@@ -75,8 +84,7 @@ def update_note(note_id: int):
@bp.delete("/notes/<int(max=2147483647):note_id>") @bp.delete("/notes/<int(max=2147483647):note_id>")
@login_required def delete_note(note_id: int) -> ResponseReturnValue:
def delete_note(note_id: int):
row = query_one( row = query_one(
"DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id", "DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id",
(note_id, g.user_id), (note_id, g.user_id),
+83
View File
@@ -0,0 +1,83 @@
"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper."""
import base64
import functools
import hashlib
import hmac
import logging
import os
import secrets
from pathlib import Path
log = logging.getLogger(__name__)
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
def hash_password(
password: str,
pepper: bytes,
*,
salt: bytes | None = None,
iterations: int = ITERATIONS,
) -> str:
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
derived = _derive(password, pepper, salt, iterations)
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
_, iterations, salt, expected = stored.split("$")
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
return hmac.compare_digest(derived, base64.b64decode(expected))
def needs_rehash(stored: str) -> bool:
return int(stored.split("$")[1]) < ITERATIONS
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
@functools.cache
def dummy_hash() -> str:
# Verified against for unknown usernames so their response time matches a real account.
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
def load_pepper(pepper_file: Path) -> bytes:
from_env = os.environ.get("PASSWORD_PEPPER")
if from_env is not None:
if len(from_env) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return from_env.encode()
try:
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(fd, "w") as f:
f.write(secrets.token_hex(32))
log.warning(
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
"from a secrets manager.",
pepper_file,
)
pepper = pepper_file.read_text().strip()
if len(pepper) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
"the file to regenerate it (existing passwords will stop verifying)."
)
return pepper.encode()
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.1.3
psycopg2-binary>=2.9.9
+189 -2
View File
@@ -1,2 +1,189 @@
Flask>=3.0.0 # This file was autogenerated by uv via the following command:
psycopg2-binary>=2.9.9 # uv pip compile --universal --generate-hashes backend/requirements.in -o backend/requirements.txt
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via flask
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via -r backend/requirements.in
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
psycopg2-binary==2.9.13 \
--hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \
--hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \
--hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \
--hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \
--hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \
--hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \
--hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \
--hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \
--hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \
--hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \
--hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \
--hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \
--hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \
--hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \
--hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \
--hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \
--hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \
--hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \
--hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \
--hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \
--hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \
--hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \
--hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \
--hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \
--hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \
--hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \
--hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \
--hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \
--hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \
--hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \
--hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \
--hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \
--hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \
--hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \
--hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \
--hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \
--hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \
--hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \
--hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \
--hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \
--hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \
--hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \
--hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \
--hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \
--hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \
--hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \
--hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \
--hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \
--hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \
--hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \
--hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \
--hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \
--hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \
--hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \
--hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \
--hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \
--hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \
--hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \
--hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \
--hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \
--hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \
--hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \
--hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \
--hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \
--hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \
--hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \
--hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389
# via -r backend/requirements.in
werkzeug==3.1.9 \
--hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \
--hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab
# via flask
+2 -1
View File
@@ -3,9 +3,10 @@ import unittest
import psycopg2 import psycopg2
from app import app from app import create_app
HTTPS = "https://localhost" HTTPS = "https://localhost"
app = create_app()
def db_execute(sql: str, params: tuple = ()) -> list[tuple]: def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
+31 -1
View File
@@ -1,6 +1,10 @@
import os
import subprocess
import sys
from pathlib import Path from pathlib import Path
from tests.support import HTTPS, ApiTestCase, db_execute import db
from tests.support import HTTPS, ApiTestCase, app, db_execute
SCHEMA = Path(__file__).parent.parent / "schema.sql" SCHEMA = Path(__file__).parent.parent / "schema.sql"
@@ -43,3 +47,29 @@ class AppTests(ApiTestCase):
) )
self.assertEqual(response.status_code, 413) self.assertEqual(response.status_code, 413)
self.assertIn("error", response.get_json()) self.assertIn("error", response.get_json())
class ConnectionReleaseTests(ApiTestCase):
def test_closed_connection_is_discarded_instead_of_leaking_a_pool_slot(self):
pool = app.extensions["db_pool"]
with self.assertLogs("db", "WARNING"):
for _ in range(pool.maxconn + 1):
with app.app_context():
db.query("SELECT 1")
db._connection().close()
with app.app_context():
self.assertEqual(db.query("SELECT 1 AS one"), [{"one": 1}])
class AppFactoryTests(ApiTestCase):
def test_importing_the_app_module_does_not_connect_to_the_database(self):
result = subprocess.run(
[sys.executable, "-c", "import app"],
cwd=Path(__file__).parent.parent,
env=os.environ | {"DATABASE_URL": "postgresql://[email protected]:9/none"},
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
+31 -3
View File
@@ -1,9 +1,10 @@
import hashlib import hashlib
import os import os
import re
from app import app from app import create_app
from auth import hash_password from passwords import hash_password
from tests.support import ApiTestCase, db_execute from tests.support import ApiTestCase, app, db_execute
class AuthTests(ApiTestCase): class AuthTests(ApiTestCase):
@@ -156,3 +157,30 @@ class AuthTests(ApiTestCase):
"pbkdf2_sha256$600000$" "pbkdf2_sha256$600000$"
) )
) )
class AuthByDefaultTests(ApiTestCase):
PUBLIC = frozenset({"health", "auth.register", "auth.login", "auth.logout"})
def test_only_the_expected_endpoints_allow_anonymous_access(self):
public = {
endpoint
for endpoint, view in app.view_functions.items()
if getattr(view, "allow_anonymous", False)
}
self.assertEqual(public, self.PUBLIC)
def test_every_other_route_rejects_anonymous_requests(self):
for rule in app.url_map.iter_rules():
if rule.endpoint in self.PUBLIC or rule.endpoint == "static":
continue
path = re.sub(r"<[^>]+>", "1", rule.rule)
for method in rule.methods - {"HEAD", "OPTIONS"}:
with self.subTest(method=method, path=path):
self.assertEqual(self.call(method, path).status_code, 401)
def test_a_new_route_requires_login_without_any_decorator(self):
fresh = create_app()
fresh.add_url_rule("/api/new-thing", "new_thing", lambda: {"ok": True})
response = self.call("GET", "/api/new-thing", client=fresh.test_client())
self.assertEqual(response.status_code, 401)
+54 -1
View File
@@ -1,4 +1,10 @@
from tests.support import ApiTestCase import os
import threading
import time
import psycopg2
from tests.support import ApiTestCase, db_execute
class BookTests(ApiTestCase): class BookTests(ApiTestCase):
@@ -147,3 +153,50 @@ class BookIsolationTests(ApiTestCase):
self.assertEqual(response.get_json(), {"error": "Book not found"}) self.assertEqual(response.get_json(), {"error": "Book not found"})
self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), []) self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), [])
self.assertEqual(self.call("GET", path).get_json()["title"], "Dune") self.assertEqual(self.call("GET", path).get_json()["title"], "Dune")
class ConcurrentUpdateTests(ApiTestCase):
def setUp(self):
super().setUp()
self.register()
def wait_for_lock_wait(self):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
waiting = db_execute(
"SELECT count(*) FROM pg_stat_activity"
" WHERE datname = current_database() AND wait_event_type = 'Lock'"
)[0][0]
if waiting:
return
time.sleep(0.02)
self.fail("PATCH never waited on the locked book row")
def test_concurrent_updates_to_different_fields_both_persist(self):
book = self.create_book()
other_session = psycopg2.connect(os.environ["DATABASE_URL"])
result = {}
try:
with other_session.cursor() as cur:
cur.execute(
"UPDATE books SET title = 'Dune Messiah' WHERE id = %s",
(book["id"],),
)
patch = threading.Thread(
target=lambda: result.update(
response=self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
)
)
)
patch.start()
self.wait_for_lock_wait()
other_session.commit()
patch.join(timeout=10)
finally:
other_session.close()
self.assertFalse(patch.is_alive(), "PATCH did not finish")
self.assertEqual(result["response"].status_code, 200)
saved = self.call("GET", f"/api/books/{book['id']}").get_json()
self.assertEqual((saved["title"], saved["current_page"]), ("Dune Messiah", 100))
+9 -3
View File
@@ -5,7 +5,13 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from auth import ITERATIONS, hash_password, load_pepper, needs_rehash, verify_password from passwords import (
ITERATIONS,
hash_password,
load_pepper,
needs_rehash,
verify_password,
)
PEPPER = b"p" * 32 PEPPER = b"p" * 32
KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE=" KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE="
@@ -54,9 +60,9 @@ class PepperTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ): with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ):
os.environ.pop("PASSWORD_PEPPER", None) os.environ.pop("PASSWORD_PEPPER", None)
pepper_file = Path(tmp) / ".pepper" pepper_file = Path(tmp) / ".pepper"
with self.assertLogs("auth", level="WARNING"): with self.assertLogs("passwords", level="WARNING"):
first = load_pepper(pepper_file) first = load_pepper(pepper_file)
with self.assertLogs("auth", level="WARNING"): with self.assertLogs("passwords", level="WARNING"):
second = load_pepper(pepper_file) second = load_pepper(pepper_file)
self.assertEqual(first, second) self.assertEqual(first, second)
self.assertEqual(len(first), 64) self.assertEqual(len(first), 64)
+7 -3
View File
@@ -1,7 +1,11 @@
"""Request validation shared by every slice. Errors carry a status, a fix-it message, and the field.""" """Request validation shared by every slice. Errors carry a status, a fix-it message, and the field."""
from typing import Any
from flask import request from flask import request
JsonObject = dict[str, Any]
class ApiError(Exception): class ApiError(Exception):
def __init__(self, status: int, message: str, field: str | None = None): def __init__(self, status: int, message: str, field: str | None = None):
@@ -11,7 +15,7 @@ class ApiError(Exception):
self.field = field self.field = field
def json_body(allowed: set[str]) -> dict: def json_body(allowed: set[str]) -> JsonObject:
body = request.get_json(silent=True) body = request.get_json(silent=True)
if not isinstance(body, dict): if not isinstance(body, dict):
raise ApiError(400, "Request body must be a JSON object") raise ApiError(400, "Request body must be a JSON object")
@@ -36,7 +40,7 @@ def require_utf8(value: str, field: str) -> None:
) from None ) from None
def text(body: dict, field: str, max_len: int) -> str: def text(body: JsonObject, field: str, max_len: int) -> str:
value = body.get(field) value = body.get(field)
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
raise ApiError(400, f"{field} is required and must be non-blank text", field) raise ApiError(400, f"{field} is required and must be non-blank text", field)
@@ -49,7 +53,7 @@ def text(body: dict, field: str, max_len: int) -> str:
return value return value
def integer(body: dict, field: str, low: int, high: int) -> int: def integer(body: JsonObject, field: str, low: int, high: int) -> int:
value = body.get(field) value = body.get(field)
if isinstance(value, bool) or not isinstance(value, int): if isinstance(value, bool) or not isinstance(value, int):
raise ApiError(400, f"{field} must be a whole number", field) raise ApiError(400, f"{field} must be a whole number", field)
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
# The pad template's node_modules lacks this app's extra packages (router, Tailwind); a no-op once installed.
npm install --no-audit --no-fund
# Locally the deps live in the mise venv; the pad's system python3 has Flask and psycopg2 preinstalled.
PYTHON=python3
[ -x .venv/bin/python ] && PYTHON=.venv/bin/python
"$PYTHON" backend/app.py &
FLASK_PID=$!
until curl -s -o /dev/null http://localhost:5000/api/health; do
if ! kill -0 "$FLASK_PID" 2>/dev/null; then
echo "Flask exited during startup; fix the error above (often a missing dependency or an unreachable PostgreSQL) and restart." >&2
exit 1
fi
sleep 0.5
done
exec node_modules/.bin/vite
+15
View File
@@ -2,5 +2,20 @@
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
"formatter": { "formatter": {
"indentStyle": "space" "indentStyle": "space"
},
"linter": {
"rules": {
"correctness": {
"useExhaustiveDependencies": {
"level": "error",
"options": { "reportUnnecessaryDependencies": false }
}
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
} }
} }
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Library</title> <title>My Library</title>
<script>
try {
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
} catch {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+25 -2
View File
@@ -3,6 +3,11 @@ python = "3.14"
node = "24" node = "24"
ruff = "0.16.10" ruff = "0.16.10"
biome = "2.5.15" biome = "2.5.15"
uv = "0.12.21"
[tasks.lock]
description = "Re-resolve backend/requirements.in into the pinned, hashed backend/requirements.txt"
run = "uv pip compile --quiet --universal --generate-hashes backend/requirements.in -o backend/requirements.txt"
[tasks.setup] [tasks.setup]
description = "Create the venv and install backend and frontend dependencies" description = "Create the venv and install backend and frontend dependencies"
@@ -46,8 +51,12 @@ description = "Format and auto-fix Python and TypeScript"
depends = ["format:py", "format:ts"] depends = ["format:py", "format:ts"]
[tasks."lint:py"] [tasks."lint:py"]
description = "Lint the backend and verify formatting (no writes)" description = "Lint, type-check and verify formatting of the backend (no writes)"
run = ["ruff check backend", "ruff format --check backend"] run = [
"ruff check backend",
"ruff format --check backend",
"uvx --quiet --with-requirements backend/requirements.txt --with types-psycopg2==2.9.21.20260911 [email protected] --strict backend --exclude backend/tests",
]
[tasks."lint:ts"] [tasks."lint:ts"]
description = "Lint the frontend and verify formatting (no writes)" description = "Lint the frontend and verify formatting (no writes)"
@@ -56,3 +65,17 @@ run = "biome check src vite.config.ts"
[tasks.lint] [tasks.lint]
description = "Lint and format-check Python and TypeScript (CI-safe, no writes)" description = "Lint and format-check Python and TypeScript (CI-safe, no writes)"
depends = ["lint:py", "lint:ts"] depends = ["lint:py", "lint:ts"]
[tasks."test:py"]
description = "Backend integration tests against real Postgres (books_test)"
depends = ["setup", "db"]
run = ".venv/bin/python -W error -m unittest discover -s backend/tests -t backend"
[tasks."test:ts"]
description = "Frontend component tests and typecheck"
depends = ["setup"]
run = ["npm test", "npm run typecheck"]
[tasks.test]
description = "Run backend and frontend tests"
depends = ["test:py", "test:ts"]
+20
View File
@@ -8,6 +8,8 @@
"name": "flask-react-template", "name": "flask-react-template",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router": "^7.18.4" "react-router": "^7.18.4"
@@ -990,6 +992,24 @@
} }
} }
}, },
"node_modules/@fontsource-variable/newsreader": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz",
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/atkinson-hyperlegible": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz",
"integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2
View File
@@ -9,6 +9,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router": "^7.18.4" "react-router": "^7.18.4"
-3
View File
@@ -1,4 +1 @@
# The backend imports its modules as top-level names (`import db`). Without this, ruff's
# first-party detection matches the frontend's src/auth, src/books, src/notes directories
# instead, and splits backend imports into two groups.
src = ["backend"] src = ["backend"]
+43 -13
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { Fragment, type ReactNode, useState } from "react";
import { import {
BrowserRouter, BrowserRouter,
Link, Link,
@@ -6,15 +6,18 @@ import {
Outlet, Outlet,
Route, Route,
Routes, Routes,
useParams,
} from "react-router"; } from "react-router";
import { errorMessage } from "./api"; import { errorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth/AuthContext"; import { AuthProvider, useAuth } from "./auth/AuthContext";
import { LoginForm } from "./auth/LoginForm"; import { LoginForm } from "./auth/LoginForm";
import { RequireAuth } from "./auth/RequireAuth"; import { RequireAuth } from "./auth/RequireAuth";
import { BookList } from "./books/BookList";
import { BookDetail } from "./books/BookDetail"; import { BookDetail } from "./books/BookDetail";
import { BookForm } from "./books/BookForm"; 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() { export function App() {
return ( return (
@@ -34,8 +37,22 @@ export function AppRoutes() {
<Route element={<Shell />}> <Route element={<Shell />}>
<Route path="/books" element={<BookList />} /> <Route path="/books" element={<BookList />} />
<Route path="/books/new" element={<BookForm />} /> <Route path="/books/new" element={<BookForm />} />
<Route path="/books/:id/edit" element={<BookForm />} /> <Route
<Route path="/books/:id" element={<BookDetail />} /> path="/books/:id/edit"
element={
<KeyedByBookId>
<BookForm />
</KeyedByBookId>
}
/>
<Route
path="/books/:id"
element={
<KeyedByBookId>
<BookDetail />
</KeyedByBookId>
}
/>
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/books" replace />} /> <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() { function Shell() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen">
<header className="border-b border-slate-200 bg-white"> <header className="border-b border-line">
<div className="mx-auto flex max-w-4xl items-center justify-between p-4"> <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="text-lg font-semibold"> <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 My Library
</Link> </Link>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3">
<span>{user?.username}</span> <ThemeToggle />
<span className="text-muted">{user?.username}</span>
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
@@ -69,13 +98,14 @@ function Shell() {
{error && ( {error && (
<p <p
role="alert" 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} {error}
</p> </p>
)} )}
</header> </header>
<main className="mx-auto max-w-4xl p-4"> <main className="mx-auto max-w-4xl px-6 pt-11 pb-18">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
+48
View File
@@ -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();
});
});
+42
View File
@@ -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
View File
@@ -32,6 +32,13 @@ export class ApiError extends Error {
} }
let handleUnauthorized: () => void = () => {}; 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) { export function setUnauthorizedHandler(handler: () => void) {
handleUnauthorized = handler; handleUnauthorized = handler;
@@ -42,6 +49,7 @@ export async function api<T>(
path: string, path: string,
body?: unknown, body?: unknown,
): Promise<T> { ): Promise<T> {
const epoch = authEpoch;
const response = await fetch(`/api${path}`, { const response = await fetch(`/api${path}`, {
method, method,
credentials: "same-origin", credentials: "same-origin",
@@ -51,8 +59,14 @@ export async function api<T>(
if (response.status === 204) return undefined as T; if (response.status === 204) return undefined as T;
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !path.startsWith("/auth/")) if (
response.status === 401 &&
!path.startsWith("/auth/") &&
epoch === authEpoch
) {
startAuthEpoch();
handleUnauthorized(); handleUnauthorized();
}
throw new ApiError( throw new ApiError(
response.status, response.status,
data?.error ?? `Request failed with status ${response.status}`, data?.error ?? `Request failed with status ${response.status}`,
+23 -6
View File
@@ -1,11 +1,12 @@
import { import {
createContext, createContext,
type ReactNode,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
type ReactNode,
} from "react"; } from "react";
import { api, setUnauthorizedHandler, type User } from "../api"; import { api, setUnauthorizedHandler, startAuthEpoch, type User } from "../api";
type AuthValue = { type AuthValue = {
user: User | null; user: User | null;
@@ -20,26 +21,42 @@ const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const authChanged = useRef(false);
useEffect(() => { useEffect(() => {
setUnauthorizedHandler(() => setUser(null)); setUnauthorizedHandler(() => setUser(null));
api<User>("GET", "/auth/me") api<User>("GET", "/auth/me")
.then(setUser, () => setUser(null)) .then(
(me) => {
if (!authChanged.current) setUser(me);
},
() => {
if (!authChanged.current) setUser(null);
},
)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
function changeUser(next: User | null) {
authChanged.current = true;
startAuthEpoch();
setUser(next);
}
const value: AuthValue = { const value: AuthValue = {
user, user,
loading, loading,
login: async (username, password) => 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) => register: async (username, password) =>
setUser( changeUser(
await api<User>("POST", "/auth/register", { username, password }), await api<User>("POST", "/auth/register", { username, password }),
), ),
logout: async () => { logout: async () => {
await api("POST", "/auth/logout"); await api("POST", "/auth/logout");
setUser(null); changeUser(null);
}, },
}; };
return <AuthContext value={value}>{children}</AuthContext>; return <AuthContext value={value}>{children}</AuthContext>;
+29 -10
View File
@@ -1,7 +1,16 @@
import { useState, type FormEvent } from "react"; import { type SubmitEvent, useState } from "react";
import { Navigate, useLocation, type Location } from "react-router"; import { type Location, Navigate, useLocation } from "react-router";
import { errorMessage } from "../api"; 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"; import { useAuth } from "./AuthContext";
export function LoginForm() { export function LoginForm() {
@@ -22,7 +31,7 @@ export function LoginForm() {
const isLogin = mode === "login"; const isLogin = mode === "login";
async function submit(event: FormEvent) { async function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setBusy(true); setBusy(true);
@@ -36,12 +45,21 @@ export function LoginForm() {
} }
return ( return (
<div className="min-h-screen bg-slate-50 px-4 pt-16"> <div className="min-h-screen px-4 pt-4 pb-16">
<main className={`mx-auto max-w-sm ${card}`}> <div className="mb-8 flex justify-end">
<h1 className="mb-4 text-2xl font-semibold"> <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"} {isLogin ? "Sign in" : "Create account"}
</h1> </h1>
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-5">
<label className="block"> <label className="block">
<span className={labelText}>Username</span> <span className={labelText}>Username</span>
<input <input
@@ -69,7 +87,8 @@ export function LoginForm() {
/> />
</label> </label>
{error && ( {error && (
<p id="login-error" role="alert" className="text-sm text-red-700"> <p id="login-error" role="alert" className={errorText}>
<AlertIcon />
{error} {error}
</p> </p>
)} )}
@@ -83,7 +102,7 @@ export function LoginForm() {
</form> </form>
<button <button
type="button" type="button"
className="mt-4 text-sm text-indigo-700 underline" className={`mt-5 min-h-11 ${linkText}`}
onClick={() => { onClick={() => {
setMode(isLogin ? "register" : "login"); setMode(isLogin ? "register" : "login");
setError(null); setError(null);
+64 -1
View File
@@ -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 { describe, expect, it } from "vitest";
import { import {
alice, alice,
dune, dune,
genres, genres,
held,
mockApi, mockApi,
renderApp, renderApp,
signedIn, signedIn,
@@ -108,3 +109,65 @@ describe("auth flow", () => {
).toBe(true); ).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();
});
});
+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 { describe, expect, it } from "vitest";
import { AppRoutes } from "../App";
import type { Book } from "../api"; 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 { function withProgress(current_page: number): Book {
const status = const status =
@@ -23,14 +27,18 @@ describe("BookDetail", () => {
], ],
}); });
const user = renderApp("/books/7"); 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"); const input = screen.getByLabelText("Current page");
await user.clear(input); await user.clear(input);
await user.type(input, "200"); await user.type(input, "200");
await user.click(screen.getByRole("button", { name: "Save progress" })); 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({ expect(calls).toContainEqual({
method: "PATCH", method: "PATCH",
path: "/api/books/7", path: "/api/books/7",
@@ -47,7 +55,9 @@ describe("BookDetail", () => {
await user.click( await user.click(
await screen.findByRole("button", { name: "Mark finished" }), 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(); 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 { 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 { import {
card, card,
dangerButton, dangerButton,
errorText,
inputClass, inputClass,
labelText, labelText,
linkText,
pageTitle,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
} from "../ui"; } from "../ui";
import { NotesJournal } from "../notes/NotesJournal";
import { ProgressBar, StatusBadge } from "./Progress"; import { ProgressBar, StatusBadge } from "./Progress";
export function BookDetail() { export function BookDetail() {
@@ -23,6 +27,7 @@ export function BookDetail() {
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [progressError, setProgressError] = useState<string | null>(null); const [progressError, setProgressError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -51,11 +56,8 @@ export function BookDetail() {
if (loadError?.notFound) { if (loadError?.notFound) {
return ( return (
<div className={card}> <div className={card}>
<h1 className="text-xl font-semibold">Book not found</h1> <h1 className={pageTitle}>Book not found</h1>
<Link <Link to="/books" className={`mt-3 inline-block ${linkText}`}>
to="/books"
className="mt-2 inline-block text-indigo-700 underline"
>
← Back to library ← Back to library
</Link> </Link>
</div> </div>
@@ -63,8 +65,11 @@ export function BookDetail() {
} }
if (loadError) { if (loadError) {
return ( return (
<div role="alert" className="flex items-center gap-3 text-red-700"> <div role="alert" className="flex flex-wrap items-center gap-3">
<span>{loadError.message}</span> <span className={errorText}>
<AlertIcon />
{loadError.message}
</span>
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
@@ -75,10 +80,12 @@ export function BookDetail() {
</div> </div>
); );
} }
if (!book) return <p className="text-slate-500">Loading…</p>; if (!book) return <p className="text-muted">Loading…</p>;
const current = book; const current = book;
async function saveProgress(currentPage: number) { async function saveProgress(currentPage: number) {
if (saving) return;
setSaving(true);
setProgressError(null); setProgressError(null);
try { try {
const updated = await api<Book>("PATCH", `/books/${current.id}`, { const updated = await api<Book>("PATCH", `/books/${current.id}`, {
@@ -88,6 +95,8 @@ export function BookDetail() {
setPage(String(updated.current_page)); setPage(String(updated.current_page));
} catch (err) { } catch (err) {
setProgressError(errorMessage(err)); setProgressError(errorMessage(err));
} finally {
setSaving(false);
} }
} }
@@ -101,28 +110,33 @@ export function BookDetail() {
} }
} }
function submitProgress(event: FormEvent) { function submitProgress(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
void saveProgress(Number(page)); void saveProgress(Number(page));
} }
return ( return (
<article className="space-y-6"> <article className="space-y-7">
<Link to="/books" className="text-sm text-indigo-700 underline"> <Link
to="/books"
className={`inline-flex min-h-11 items-center ${linkText}`}
>
← Back to library ← Back to library
</Link> </Link>
<header className={card}> <header className={`${card} space-y-5 sm:p-7`}>
<div className="flex items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-semibold">{book.title}</h1> <h1 className="font-serif text-4xl leading-tight font-medium">
<p className="text-slate-600">{`${book.author} · ${book.genre.name}`}</p> {book.title}
</h1>
<p className="mt-1 text-lg text-muted">{`${book.author} · ${book.genre.name}`}</p>
</div> </div>
<StatusBadge status={book.status} /> <StatusBadge status={book.status} />
</div> </div>
<ProgressBar book={book} /> <ProgressBar book={book} />
<form <form
onSubmit={submitProgress} onSubmit={submitProgress}
className="mt-4 flex flex-wrap items-end gap-2" className="flex flex-wrap items-end gap-3"
> >
<label> <label>
<span className={labelText}>Current page</span> <span className={labelText}>Current page</span>
@@ -138,28 +152,25 @@ export function BookDetail() {
aria-describedby={progressError ? "progress-error" : undefined} aria-describedby={progressError ? "progress-error" : undefined}
/> />
</label> </label>
<button type="submit" className={primaryButton}> <button type="submit" disabled={saving} className={primaryButton}>
Save progress Save progress
</button> </button>
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
disabled={book.current_page === book.total_pages} disabled={saving || book.current_page === book.total_pages}
onClick={() => void saveProgress(book.total_pages)} onClick={() => void saveProgress(book.total_pages)}
> >
Mark finished Mark finished
</button> </button>
</form> </form>
{progressError && ( {progressError && (
<p <p id="progress-error" role="alert" className={errorText}>
id="progress-error" <AlertIcon />
role="alert"
className="mt-2 text-sm text-red-700"
>
{progressError} {progressError}
</p> </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}> <Link to={`/books/${book.id}/edit`} className={secondaryButton}>
Edit Edit
</Link> </Link>
@@ -168,6 +179,7 @@ export function BookDetail() {
className={dangerButton} className={dangerButton}
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
> >
<TrashIcon />
Delete book Delete book
</button> </button>
</div> </div>
@@ -175,10 +187,10 @@ export function BookDetail() {
<dialog <dialog
open open
aria-labelledby="delete-prompt" 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> <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 <button
type="button" type="button"
className={dangerButton} className={dangerButton}
@@ -195,7 +207,8 @@ export function BookDetail() {
</button> </button>
</div> </div>
{deleteError && ( {deleteError && (
<p role="alert" className="mt-2 text-sm text-red-700"> <p role="alert" className={`mt-3 ${errorText}`}>
<AlertIcon />
{deleteError} {deleteError}
</p> </p>
)} )}
+38 -7
View File
@@ -79,13 +79,7 @@ describe("BookForm", () => {
expect(calls).toContainEqual({ expect(calls).toContainEqual({
method: "PATCH", method: "PATCH",
path: "/api/books/7", path: "/api/books/7",
body: { body: { title: "Dune Messiah" },
title: "Dune Messiah",
author: "Frank Herbert",
genre_id: 5,
total_pages: 380,
current_page: 142,
},
}); });
}); });
@@ -104,4 +98,41 @@ describe("BookForm", () => {
await user.click(screen.getByRole("button", { name: "Retry" })); await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByLabelText("Title")).toHaveValue("Dune"); 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 { 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 { import {
card, card,
errorText,
inputClass, inputClass,
labelText, labelText,
linkText,
pageTitle,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
} from "../ui"; } from "../ui";
@@ -33,6 +37,7 @@ export function BookForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { genres, error: genresError } = useGenres(); const { genres, error: genresError } = useGenres();
const [fields, setFields] = useState<Fields>(EMPTY); const [fields, setFields] = useState<Fields>(EMPTY);
const [loaded, setLoaded] = useState<Fields>(EMPTY);
const [loading, setLoading] = useState(editing); const [loading, setLoading] = useState(editing);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [error, setError] = useState<FormError | null>(null); const [error, setError] = useState<FormError | null>(null);
@@ -47,13 +52,15 @@ export function BookForm() {
api<Book>("GET", `/books/${id}`).then( api<Book>("GET", `/books/${id}`).then(
(book) => { (book) => {
if (cancelled) return; if (cancelled) return;
setFields({ const current = {
title: book.title, title: book.title,
author: book.author, author: book.author,
genre_id: String(book.genre.id), genre_id: String(book.genre.id),
total_pages: String(book.total_pages), total_pages: String(book.total_pages),
current_page: String(book.current_page), current_page: String(book.current_page),
}); };
setFields(current);
setLoaded(current);
setLoading(false); setLoading(false);
}, },
(err) => { (err) => {
@@ -78,10 +85,8 @@ export function BookForm() {
: {}; : {};
} }
async function submit(event: FormEvent) { async function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
setError(null);
setBusy(true);
const payload = { const payload = {
title: fields.title, title: fields.title,
author: fields.author, author: fields.author,
@@ -89,9 +94,23 @@ export function BookForm() {
total_pages: Number(fields.total_pages), total_pages: Number(fields.total_pages),
current_page: Number(fields.current_page), 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 { try {
const saved = editing const saved = editing
? await api<Book>("PATCH", `/books/${id}`, payload) ? await api<Book>("PATCH", `/books/${id}`, changes)
: await api<Book>("POST", "/books", payload); : await api<Book>("POST", "/books", payload);
navigate(`/books/${saved.id}`); navigate(`/books/${saved.id}`);
} catch (err) { } catch (err) {
@@ -106,8 +125,11 @@ export function BookForm() {
if (loadError) { if (loadError) {
return ( return (
<div role="alert" className={card}> <div role="alert" className={card}>
<p className="text-red-700">{loadError}</p> <p className={errorText}>
<div className="mt-2 flex items-center gap-3"> <AlertIcon />
{loadError}
</p>
<div className="mt-3 flex flex-wrap items-center gap-4">
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
@@ -115,20 +137,18 @@ export function BookForm() {
> >
Retry Retry
</button> </button>
<Link to="/books" className="text-indigo-700 underline"> <Link to="/books" className={linkText}>
← Back to library ← Back to library
</Link> </Link>
</div> </div>
</div> </div>
); );
} }
if (loading) return <p className="text-slate-500">Loading…</p>; if (loading) return <p className="text-muted">Loading…</p>;
return ( return (
<form onSubmit={submit} className={`space-y-4 ${card}`}> <form onSubmit={submit} className={`mx-auto max-w-xl space-y-5 ${card}`}>
<h1 className="text-2xl font-semibold"> <h1 className={pageTitle}>{editing ? "Edit book" : "Add a book"}</h1>
{editing ? "Edit book" : "Add a book"}
</h1>
<label className="block"> <label className="block">
<span className={labelText}>Title</span> <span className={labelText}>Title</span>
<input <input
@@ -169,13 +189,13 @@ export function BookForm() {
</select> </select>
</label> </label>
{genresError && ( {genresError && (
<p <p role="alert" className={errorText}>
role="alert" <AlertIcon />
className="text-sm text-red-700" {`Could not load genres: ${genresError}`}
>{`Could not load genres: ${genresError}`}</p> </p>
)} )}
<div className="flex gap-4"> <div className="flex flex-wrap gap-4">
<label className="block"> <label className="block min-w-36 flex-1">
<span className={labelText}>Total pages</span> <span className={labelText}>Total pages</span>
<input <input
type="number" type="number"
@@ -188,7 +208,7 @@ export function BookForm() {
{...invalid("total_pages")} {...invalid("total_pages")}
/> />
</label> </label>
<label className="block"> <label className="block min-w-36 flex-1">
<span className={labelText}>Current page</span> <span className={labelText}>Current page</span>
<input <input
type="number" type="number"
@@ -203,11 +223,12 @@ export function BookForm() {
</label> </label>
</div> </div>
{error && ( {error && (
<p id="form-error" role="alert" className="text-sm text-red-700"> <p id="form-error" role="alert" className={errorText}>
<AlertIcon />
{error.message} {error.message}
</p> </p>
)} )}
<div className="flex gap-2"> <div className="flex flex-wrap gap-3 pt-1">
<button type="submit" disabled={busy} className={primaryButton}> <button type="submit" disabled={busy} className={primaryButton}>
{editing ? "Save changes" : "Add book"} {editing ? "Save changes" : "Add book"}
</button> </button>
+1 -1
View File
@@ -13,7 +13,7 @@ describe("BookList", () => {
screen.getByText("Frank Herbert · Science Fiction"), screen.getByText("Frank Herbert · Science Fiction"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Reading")).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( expect(screen.getByRole("link", { name: /Dune/ })).toHaveAttribute(
"href", "href",
"/books/7", "/books/7",
+46 -27
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router"; 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 { import {
card, errorText,
inputClass, inputClass,
labelText, labelText,
pageTitle,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
} from "../ui"; } from "../ui";
@@ -23,7 +25,8 @@ export function BookList() {
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const { genres, error: genresError } = useGenres(); const { genres, error: genresError } = useGenres();
function updateParam(key: string, value: string) { const updateParam = useCallback(
(key: string, value: string) =>
setParams( setParams(
(prev) => { (prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
@@ -32,8 +35,9 @@ export function BookList() {
return next; return next;
}, },
{ replace: true }, { replace: true },
),
[setParams],
); );
}
useEffect(() => setSearch(q), [q]); useEffect(() => setSearch(q), [q]);
@@ -44,7 +48,7 @@ export function BookList() {
SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS,
); );
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search, q]); }, [search, q, updateParam]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -67,18 +71,30 @@ export function BookList() {
}, [q, genre, attempt]); }, [q, genre, attempt]);
return ( return (
<section> <section className="space-y-5">
<div className="mb-4 flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<label className="min-w-48 grow"> <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> <span className={labelText}>Search title or author</span>
<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 <input
type="search" type="search"
className={inputClass} className={`${inputClass} pl-11`}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</span>
</label> </label>
<label> <label className="flex-[0_1_220px]">
<span className={labelText}>Genre</span> <span className={labelText}>Genre</span>
<select <select
className={inputClass} className={inputClass}
@@ -93,18 +109,19 @@ export function BookList() {
))} ))}
</select> </select>
</label> </label>
<Link to="/books/new" className={primaryButton}>
Add book
</Link>
</div> </div>
{genresError && ( {genresError && (
<p role="alert" className="mb-3 text-sm text-red-700"> <p role="alert" className={errorText}>
<AlertIcon />
{`Could not load genres: ${genresError}`} {`Could not load genres: ${genresError}`}
</p> </p>
)} )}
{error ? ( {error ? (
<div role="alert" className="flex items-center gap-3 text-red-700"> <div role="alert" className="flex flex-wrap items-center gap-3">
<span>{error}</span> <span className={errorText}>
<AlertIcon />
{error}
</span>
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
@@ -114,27 +131,29 @@ export function BookList() {
</button> </button>
</div> </div>
) : books === null ? ( ) : books === null ? (
<p className="text-slate-500">Loading…</p> <p className="text-muted">Loading…</p>
) : books.length === 0 ? ( ) : books.length === 0 ? (
<p className="text-slate-600"> <p className="text-muted">
{q.trim() || genre {q.trim() || genre
? "No matches. Try a different search or genre." ? "No matches. Try a different search or genre."
: "No books yet. Add your first one."} : "No books yet. Add your first one."}
</p> </p>
) : ( ) : (
<ul className="grid gap-3 sm:grid-cols-2"> <ul className="overflow-hidden rounded-xl border border-line bg-surface">
{books.map((book) => ( {books.map((book) => (
<li key={book.id}> <li key={book.id} className="border-b border-line last:border-b-0">
<Link <Link
to={`/books/${book.id}`} 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"> <div className="min-w-0 flex-[1_1_260px]">
<h2 className="font-semibold">{book.title}</h2> <h2 className="font-serif text-xl leading-snug font-semibold">
<StatusBadge status={book.status} /> {book.title}
</h2>
<p className="text-sm text-muted">{`${book.author} · ${book.genre.name}`}</p>
</div> </div>
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p> <StatusBadge status={book.status} />
<ProgressBar book={book} /> <ProgressBar book={book} compact />
</Link> </Link>
</li> </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 type { Book, ReadingStatus } from "../api";
import { FinishedIcon, NotStartedIcon, ReadingIcon } from "../icons";
const STATUS: Record<ReadingStatus, { label: string; style: string }> = { const STATUS: Record<
not_started: { label: "Not started", style: "bg-slate-100 text-slate-700" }, ReadingStatus,
reading: { label: "Reading", style: "bg-amber-100 text-amber-800" }, { label: string; style: string; icon: ReactNode }
finished: { label: "Finished", style: "bg-emerald-100 text-emerald-800" }, > = {
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 }) { export function StatusBadge({ status }: { status: ReadingStatus }) {
return ( return (
<span <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} {STATUS[status].label}
</span> </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); const percent = Math.round((book.current_page / book.total_pages) * 100);
return ( return (
<div className="mt-3"> <div className={compact ? "flex flex-[0_1_200px] items-center gap-3" : ""}>
<progress <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} value={book.current_page}
max={book.total_pages} max={book.total_pages}
aria-label={`Reading progress for ${book.title}`} 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> </div>
); );
} }
+93
View File
@@ -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>
);
+57
View File
@@ -1 +1,58 @@
@import "tailwindcss"; @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
View File
@@ -1,9 +1,19 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import "@fontsource/atkinson-hyperlegible/400.css";
import "@fontsource/atkinson-hyperlegible/700.css";
import "@fontsource-variable/newsreader";
import "./index.css"; 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> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
+72 -3
View File
@@ -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 { describe, expect, it } from "vitest";
import type { Note } from "../api"; import type { Note } from "../api";
import { dune, renderApp, signedIn } from "../test/helpers"; import { dune, held, renderApp, signedIn } from "../test/helpers";
const older: Note = { const older: Note = {
id: 11, id: 11,
@@ -61,7 +61,8 @@ describe("NotesJournal", () => {
], ],
}); });
const user = renderApp("/books/7"); 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" })); await user.click(within(item).getByRole("button", { name: "Edit entry" }));
const editor = within(item).getByLabelText("Edit entry"); const editor = within(item).getByLabelText("Edit entry");
await user.clear(editor); await user.clear(editor);
@@ -124,3 +125,71 @@ describe("NotesJournal", () => {
expect(calls.filter((c) => c.method === "POST")).toHaveLength(1); 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
View File
@@ -1,55 +1,66 @@
import { useEffect, useState, type FormEvent } from 'react' import { type SubmitEvent, useEffect, useState } from "react";
import { api, errorMessage, type Note } from '../api' import { api, errorMessage, type Note } from "../api";
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui' 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 }) { export function NotesJournal({ bookId }: { bookId: number }) {
const [notes, setNotes] = useState<Note[] | null>(null) const [notes, setNotes] = useState<Note[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0) const [attempt, setAttempt] = useState(0);
const [draft, setDraft] = useState('') const [draft, setDraft] = useState("");
const [addError, setAddError] = useState<string | null>(null) const [addError, setAddError] = useState<string | null>(null);
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false;
setLoadError(null) setLoadError(null);
api<Note[]>('GET', `/books/${bookId}/notes`).then( api<Note[]>("GET", `/books/${bookId}/notes`).then(
(found) => { (found) => {
if (!cancelled) setNotes(found) if (!cancelled) setNotes(found);
}, },
(err) => { (err) => {
if (!cancelled) setLoadError(errorMessage(err)) if (!cancelled) setLoadError(errorMessage(err));
}, },
) );
return () => { return () => {
cancelled = true cancelled = true;
} };
}, [bookId, attempt]) }, [bookId, attempt]);
async function addNote(event: FormEvent) { async function addNote(event: SubmitEvent) {
event.preventDefault() event.preventDefault();
if (adding) return if (adding || notes === null) return;
setAdding(true) setAdding(true);
setAddError(null) setAddError(null);
try { try {
const note = await api<Note>('POST', `/books/${bookId}/notes`, { body: draft }) const note = await api<Note>("POST", `/books/${bookId}/notes`, {
setNotes((prev) => [note, ...(prev ?? [])]) body: draft,
setDraft('') });
setNotes((prev) => [note, ...(prev ?? [])]);
setDraft("");
} catch (err) { } catch (err) {
setAddError(errorMessage(err)) setAddError(errorMessage(err));
} finally { } finally {
setAdding(false) setAdding(false);
} }
} }
return ( return (
<section aria-labelledby="journal-heading" className={card}> <section aria-labelledby="journal-heading" className="space-y-4">
<h2 id="journal-heading" className="mb-3 text-lg font-semibold"> <h2 id="journal-heading" className="font-serif text-3xl font-medium">
Reading journal Reading journal
</h2> </h2>
<form onSubmit={addNote} className="space-y-2"> <form onSubmit={addNote} className="space-y-3">
<label className="block"> <label className="block">
<span className={labelText}>New journal entry</span> <span className={labelText}>New journal entry</span>
<textarea <textarea
@@ -58,81 +69,118 @@ export function NotesJournal({ bookId }: { bookId: number }) {
maxLength={MAX_NOTE} maxLength={MAX_NOTE}
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
readOnly={adding}
required required
aria-describedby={addError ? 'add-note-error' : undefined} aria-describedby={addError ? "add-note-error" : undefined}
/> />
</label> </label>
<button type="submit" disabled={adding} className={primaryButton}> <button
type="submit"
disabled={adding || notes === null}
className={primaryButton}
>
Add entry Add entry
</button> </button>
{addError && ( {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} {addError}
</p> </p>
)} )}
</form> </form>
{loadError ? ( {loadError ? (
<div role="alert" className="mt-4 flex items-center gap-3 text-red-700"> <div role="alert" className="flex flex-wrap items-center gap-3">
<span>{loadError}</span> <span className={errorText}>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}> <AlertIcon />
{loadError}
</span>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry Retry
</button> </button>
</div> </div>
) : notes === null ? ( ) : notes === null ? (
<p className="mt-4 text-slate-500">Loading…</p> <p className="text-muted">Loading…</p>
) : notes.length === 0 ? ( ) : 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) => ( {notes.map((note) => (
<NoteItem <NoteItem
key={note.id} key={note.id}
note={note} note={note}
onSaved={(saved) => setNotes((prev) => prev!.map((n) => (n.id === saved.id ? saved : n)))} onSaved={(saved) =>
onDeleted={() => setNotes((prev) => prev!.filter((n) => n.id !== note.id))} setNotes(
(prev) =>
prev?.map((n) => (n.id === saved.id ? saved : n)) ?? null,
)
}
onDeleted={() =>
setNotes(
(prev) => prev?.filter((n) => n.id !== note.id) ?? null,
)
}
/> />
))} ))}
</ol> </ol>
)} )}
</section> </section>
) );
} }
function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: Note) => void; onDeleted: () => void }) { function NoteItem({
const [editing, setEditing] = useState(false) note,
const [text, setText] = useState(note.body) onSaved,
const [error, setError] = useState<string | null>(null) onDeleted,
const errorId = `note-${note.id}-error` }: {
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) { async function save(event: SubmitEvent) {
event.preventDefault() event.preventDefault();
setError(null) if (saving) return;
setSaving(true);
setError(null);
try { try {
onSaved(await api<Note>('PATCH', `/notes/${note.id}`, { body: text })) onSaved(await api<Note>("PATCH", `/notes/${note.id}`, { body: text }));
setEditing(false) setEditing(false);
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err));
} finally {
setSaving(false);
} }
} }
async function remove() { async function remove() {
setError(null) setError(null);
try { try {
await api('DELETE', `/notes/${note.id}`) await api("DELETE", `/notes/${note.id}`);
onDeleted() onDeleted();
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err));
} }
} }
return ( return (
<li className="border-t border-slate-200 py-3"> <li className={`${card} px-6 py-5`}>
<p className="text-xs text-slate-500"> <p className="text-sm text-muted">
<time dateTime={note.created_at}>{new Date(note.created_at).toLocaleString()}</time> <time dateTime={note.created_at}>
{note.updated_at !== note.created_at && ' · edited'} {new Date(note.created_at).toLocaleString()}
</time>
{note.updated_at !== note.created_at && " · edited"}
</p> </p>
{editing ? ( {editing ? (
<form onSubmit={save} className="mt-1 space-y-2"> <form onSubmit={save} className="mt-2 space-y-3">
<label className="block"> <label className="block">
<span className="sr-only">Edit entry</span> <span className="sr-only">Edit entry</span>
<textarea <textarea
@@ -141,20 +189,21 @@ function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: No
maxLength={MAX_NOTE} maxLength={MAX_NOTE}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
readOnly={saving}
required required
aria-describedby={error ? errorId : undefined} aria-describedby={error ? errorId : undefined}
/> />
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className={primaryButton}> <button type="submit" disabled={saving} className={primaryButton}>
Save Save
</button> </button>
<button <button
type="button" type="button"
className={secondaryButton} className={secondaryButton}
onClick={() => { onClick={() => {
setEditing(false) setEditing(false);
setText(note.body) setText(note.body);
}} }}
> >
Cancel Cancel
@@ -163,22 +212,34 @@ function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: No
</form> </form>
) : ( ) : (
<> <>
<p className="mt-1 whitespace-pre-wrap">{note.body}</p> <p className="mt-2 font-serif text-lg leading-relaxed whitespace-pre-wrap">
<div className="mt-2 flex gap-2"> {note.body}
<button type="button" className={secondaryButton} onClick={() => setEditing(true)}> </p>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
className={secondaryButton}
onClick={() => setEditing(true)}
>
Edit entry Edit entry
</button> </button>
<button type="button" className={dangerButton} onClick={() => void remove()}> <button
type="button"
className={dangerButton}
onClick={() => void remove()}
>
<TrashIcon />
Delete entry Delete entry
</button> </button>
</div> </div>
</> </>
)} )}
{error && ( {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} {error}
</p> </p>
)} )}
</li> </li>
) );
} }
+11 -3
View File
@@ -2,8 +2,8 @@ import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { vi } from "vitest"; import { vi } from "vitest";
import type { Book, Genre, User } from "../api";
import { AppRoutes } from "../App"; import { AppRoutes } from "../App";
import type { Book, Genre, User } from "../api";
import { AuthProvider } from "../auth/AuthContext"; import { AuthProvider } from "../auth/AuthContext";
export const alice: User = { id: 1, username: "alice" }; export const alice: User = { id: 1, username: "alice" };
@@ -24,9 +24,17 @@ export const dune: Book = {
}; };
type Reply = [status: number, body?: unknown]; 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 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[] { export function mockApi(routes: Record<string, Handler>): Call[] {
const calls: Call[] = []; const calls: Call[] = [];
vi.stubGlobal( vi.stubGlobal(
@@ -39,7 +47,7 @@ export function mockApi(routes: Record<string, Handler>): Call[] {
calls.push({ method, path: url.pathname + url.search, body }); calls.push({ method, path: url.pathname + url.search, body });
const handler = routes[`${method} ${url.pathname}`]; const handler = routes[`${method} ${url.pathname}`];
const [status, data] = handler const [status, data] = handler
? handler({ body, url }) ? await handler({ body, url })
: [500, { error: `No mock for ${method} ${url.pathname}` }]; : [500, { error: `No mock for ${method} ${url.pathname}` }];
return new Response(status === 204 ? null : JSON.stringify(data), { return new Response(status === 204 ? null : JSON.stringify(data), {
status, status,
+12 -7
View File
@@ -1,9 +1,14 @@
export const card = "rounded-lg bg-white p-4 shadow"; export const card = "rounded-xl border border-line bg-surface p-6";
export const labelText = "mb-1 block text-sm font-medium text-slate-700"; 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 = 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 = 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"; "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-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600`; export const primaryButton = `${button} bg-accent text-on-accent hover:bg-accent-hover`;
export const secondaryButton = `${button} border border-slate-300 bg-white text-slate-800 hover:bg-slate-100 focus-visible:outline-indigo-600`; export const secondaryButton = `${button} border border-control text-ink hover:bg-line`;
export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`; export const dangerButton = `${button} border border-danger text-danger hover:bg-danger/10`;
+24 -7
View File
@@ -1,14 +1,31 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import tailwindcss from "@tailwindcss/vite";
import tailwindcss from '@tailwindcss/vite' import react from "@vitejs/plugin-react";
import { createLogger, defineConfig } from "vite";
// Set only inside a CoderPad pad, whose preview proxies port 3000 and the HMR websocket.
const padHost = process.env.PROJECTS_BASE_HOST;
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { proxy: { '/api': 'http://127.0.0.1:5000' } }, server: {
proxy: { "/api": { target: "http://127.0.0.1:5000", changeOrigin: true } },
...(padHost
? {
host: "0.0.0.0",
port: 3000,
hmr: { clientPort: Number(process.env.WEBSOCKET_PORT) },
allowedHosts: [`.${padHost}`],
}
: {}),
},
...(padHost
? { customLogger: createLogger("info", { prefix: "[coderpad]" }) }
: {}),
test: { test: {
environment: 'jsdom', environment: "jsdom",
setupFiles: ['./src/test/setup.ts'], setupFiles: ["./src/test/setup.ts"],
unstubGlobals: true, unstubGlobals: true,
}, },
}) });