- 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
84 lines
2.1 KiB
TypeScript
84 lines
2.1 KiB
TypeScript
export type User = { id: number; username: string };
|
|
export type Genre = { id: number; name: string };
|
|
export type ReadingStatus = "not_started" | "reading" | "finished";
|
|
export type Book = {
|
|
id: number;
|
|
title: string;
|
|
author: string;
|
|
genre: Genre;
|
|
total_pages: number;
|
|
current_page: number;
|
|
status: ReadingStatus;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
export type Note = {
|
|
id: number;
|
|
book_id: number;
|
|
body: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly field?: string;
|
|
|
|
constructor(status: number, message: string, field?: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.field = field;
|
|
}
|
|
}
|
|
|
|
let handleUnauthorized: () => void = () => {};
|
|
let authEpoch = 0;
|
|
|
|
// A 401 only means "signed out" for the session that sent the request; one started before
|
|
// a later login, logout or expiry must not end the session that replaced it.
|
|
export function startAuthEpoch() {
|
|
authEpoch += 1;
|
|
}
|
|
|
|
export function setUnauthorizedHandler(handler: () => void) {
|
|
handleUnauthorized = handler;
|
|
}
|
|
|
|
export async function api<T>(
|
|
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const epoch = authEpoch;
|
|
const response = await fetch(`/api${path}`, {
|
|
method,
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
if (response.status === 204) return undefined as T;
|
|
const data = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
if (
|
|
response.status === 401 &&
|
|
!path.startsWith("/auth/") &&
|
|
epoch === authEpoch
|
|
) {
|
|
startAuthEpoch();
|
|
handleUnauthorized();
|
|
}
|
|
throw new ApiError(
|
|
response.status,
|
|
data?.error ?? `Request failed with status ${response.status}`,
|
|
data?.field,
|
|
);
|
|
}
|
|
return data as T;
|
|
}
|
|
|
|
export function errorMessage(error: unknown): string {
|
|
return error instanceof ApiError
|
|
? error.message
|
|
: "Could not reach the server; check your connection and try again";
|
|
}
|