Compare commits
8
Commits
feat/phase1
...
task/17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9546fa13f2 | ||
|
|
4608c977b7 | ||
|
|
5fa59310f3 | ||
|
|
d204775235 | ||
|
|
d547355e5c | ||
|
|
439d138967 | ||
|
|
70bc6c36d3 | ||
|
|
0bb871cd19 |
@@ -23,6 +23,10 @@ Dark, calm, dense. Three panes on desktop (nav · library list · entry detail);
|
||||
- Type: Inter for the UI; Gelasio for long-form review text.
|
||||
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
|
||||
- Orange means "a platform changed something". Never use it decoratively.
|
||||
- Text on `--warn` (the Changes count, the "See what changed" button) is dark `#1c0f04` (6.4:1), never white: white on `#f97316` is 2.8:1 and fails WCAG AA.
|
||||
- Sidebar group labels ("Sources") use `--text-faint`. The mockup's `#6f69a3` is 3.6:1 on `--surface-0` and fails AA.
|
||||
- Placeholders on `--surface-3` inputs use `--text-muted`: `--text-faint` there is 4.15:1.
|
||||
- Fonts are self-hosted in `apps/web/src/fonts/` (Inter, Gelasio; both OFL). The app makes no third-party font requests.
|
||||
- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover.
|
||||
|
||||
## Media type colours (shared by both surfaces)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||
import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import type { AttachmentEntity } from "@sttracker/db/schema";
|
||||
import { z } from "zod";
|
||||
@@ -18,6 +18,18 @@ export function safeFilename(name: string): string {
|
||||
}
|
||||
|
||||
export const attachmentRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "attachments",
|
||||
summary: "Attachments linked to one of your records (oldest first), each with its links.",
|
||||
auth: "any:read",
|
||||
query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }),
|
||||
handler: async ({ deps, user, query }) => {
|
||||
await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) })));
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "attachments",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { getPreferences, RATING_SCALES, updatePreferences } from "@sttracker/core/preferences";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import { z } from "zod";
|
||||
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||
import { visibility } from "./schemas.ts";
|
||||
|
||||
const credentials = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
@@ -9,6 +11,13 @@ const credentials = z.object({
|
||||
});
|
||||
|
||||
export const authRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "auth/status",
|
||||
summary: "Whether any account exists yet (the web app shows first-run registration when not).",
|
||||
auth: "none",
|
||||
handler: async ({ deps }) => ({ hasUsers: await hasUsers(deps.db) }),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/register",
|
||||
@@ -41,6 +50,21 @@ export const authRoutes = [
|
||||
},
|
||||
}),
|
||||
route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/preferences",
|
||||
summary: "Your preferences: rating scale and default visibility.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user }) => getPreferences(deps.db, user.id),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "me/preferences",
|
||||
summary: "Update your preferences (partial).",
|
||||
auth: "any:write",
|
||||
body: z.object({ ratingScale: z.enum(RATING_SCALES), defaultVisibility: visibility }).partial().strict(),
|
||||
handler: async ({ deps, user, body }) => updatePreferences(deps.db, user.id, body as Parameters<typeof updatePreferences>[2]),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/tokens",
|
||||
|
||||
@@ -286,6 +286,49 @@ forEachDialect("api", (getDb) => {
|
||||
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("auth/status says whether any user exists, without auth", async () => {
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false });
|
||||
await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true });
|
||||
});
|
||||
|
||||
test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
|
||||
const q = `attachments?entity=entry&entityId=${add.id}`;
|
||||
expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]);
|
||||
const form = new FormData();
|
||||
form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }));
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", add.id);
|
||||
const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string };
|
||||
const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[];
|
||||
expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]);
|
||||
expect(list[0]!.links.length).toBe(1);
|
||||
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404);
|
||||
expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404);
|
||||
expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400);
|
||||
expect((await call(app, "GET", q)).status).toBe(401);
|
||||
});
|
||||
|
||||
test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" });
|
||||
const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } });
|
||||
expect(bad.status).toBe(400);
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400);
|
||||
const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } });
|
||||
expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" });
|
||||
await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } });
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" });
|
||||
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string };
|
||||
expect(e.visibility).toBe("friends");
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("JSON bodies need Content-Type application/json (415)", async () => {
|
||||
const res = await app.fetch(
|
||||
new Request(`${BASE}/auth/register`, {
|
||||
|
||||
+15
-1
@@ -1 +1,15 @@
|
||||
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#17152b" />
|
||||
<title>STTracker</title>
|
||||
<link rel="stylesheet" href="./src/theme.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The `/api/v1` client. Cookie auth (`credentials: "same-origin"`); every non-GET request sends
|
||||
* `X-Requested-With: sttracker` (the server's CSRF rule) and, unless it is multipart, a JSON content type.
|
||||
* Wire types mirror the server's responses; the web app cannot import `@sttracker/core`.
|
||||
*/
|
||||
import type { PartialDate, RatingScale, Status } from "./format.ts";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
export interface RequestOpts {
|
||||
body?: unknown;
|
||||
form?: FormData;
|
||||
query?: Record<string, QueryValue>;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(method: Method, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
const url = `/api/v1/${path}${qs.size ? `?${qs}` : ""}`;
|
||||
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
let body: BodyInit | undefined;
|
||||
if (method !== "GET") {
|
||||
headers["x-requested-with"] = "sttracker";
|
||||
if (opts.form) body = opts.form;
|
||||
else {
|
||||
headers["content-type"] = "application/json";
|
||||
if (opts.body !== undefined) body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await (opts.fetchImpl ?? fetch)(url, { method, headers, body, credentials: "same-origin" });
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Can't reach the server. Check your connection and try again.");
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
|
||||
if (err?.code) throw new ApiError(res.status, err.code, err.message ?? err.code);
|
||||
throw new ApiError(res.status, `http_${res.status}`, `The server answered ${res.status}. Try again in a moment.`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: "admin" | "member";
|
||||
}
|
||||
|
||||
export interface EntryView {
|
||||
id: string;
|
||||
workId: string;
|
||||
status: Status;
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
privateNotes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
visibility: "private" | "friends" | "public";
|
||||
overrides: { title?: string; coverUrl?: string; year?: number | null };
|
||||
mediaTypeOverride: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null };
|
||||
draft: boolean;
|
||||
childSummary: { total: number; completed: number } | null;
|
||||
}
|
||||
|
||||
export type EntryPatch = Partial<
|
||||
Pick<EntryView, "status" | "rating" | "review" | "privateNotes" | "favorite" | "tags" | "customFields" | "visibility" | "overrides">
|
||||
>;
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: string;
|
||||
entryId: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
progress: Record<string, number | string>;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Copy {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
titleHint: string | null;
|
||||
format: string | null;
|
||||
acquiredFrom: string | null;
|
||||
acquiredAt: PartialDate | null;
|
||||
priceCents: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
caption: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
ratingScale: RatingScale;
|
||||
defaultVisibility: "private" | "friends" | "public";
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lastRunAt: string | null;
|
||||
hasCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
connectionId: string | null;
|
||||
targetEntity: string;
|
||||
targetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
actor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomFieldDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
id: string;
|
||||
media_type: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
cover_url: string;
|
||||
creators: { name: string; role?: string }[];
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
workId: string;
|
||||
candidateWorkId: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Endpoints
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface EntryQuery {
|
||||
q?: string;
|
||||
mediaType?: string;
|
||||
status?: Status;
|
||||
draft?: boolean;
|
||||
parentId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
authStatus: () => request<{ hasUsers: boolean }>("GET", "auth/status"),
|
||||
me: () => request<User>("GET", "me"),
|
||||
preferences: () => request<Preferences>("GET", "me/preferences"),
|
||||
updatePreferences: (patch: Partial<Preferences>) => request<Preferences>("PATCH", "me/preferences", { body: patch }),
|
||||
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
|
||||
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
|
||||
request<User>("POST", "auth/register", { body }),
|
||||
logout: () => request<void>("POST", "auth/logout"),
|
||||
|
||||
entries: (query: EntryQuery) => request<Page<EntryView>>("GET", "entries", { query: { ...query } }),
|
||||
entry: (id: string) => request<EntryView>("GET", `entries/${id}`),
|
||||
quickAdd: (title: string) => request<EntryView>("POST", "entries/quick", { body: { title } }),
|
||||
patchEntry: (id: string, patch: EntryPatch) => request<EntryView>("PATCH", `entries/${id}`, { body: patch }),
|
||||
moveEntry: (id: string, workId: string) => request<EntryView>("POST", `entries/${id}/move`, { body: { workId } }),
|
||||
history: (id: string) => request<HistoryRecord[]>("GET", `entries/${id}/history`),
|
||||
restore: (historyId: string) => request<void>("POST", `history/${historyId}/restore`),
|
||||
|
||||
engagements: (entryId: string) => request<Engagement[]>("GET", `entries/${entryId}/engagements`),
|
||||
addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) =>
|
||||
request<Engagement>("POST", `entries/${entryId}/engagements`, { body }),
|
||||
deleteEngagement: (id: string) => request<void>("DELETE", `engagements/${id}`),
|
||||
|
||||
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
||||
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
|
||||
|
||||
attachments: (entity: "entry" | "engagement" | "copy", entityId: string) =>
|
||||
request<Attachment[]>("GET", "attachments", { query: { entity, entityId } }),
|
||||
upload: (file: File, entityId: string) => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", entityId);
|
||||
return request<Attachment>("POST", "attachments", { form });
|
||||
},
|
||||
|
||||
sources: () => request<Connection[]>("GET", "sources"),
|
||||
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
|
||||
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
|
||||
|
||||
work: (id: string) => request<Work>("GET", `works/${id}`),
|
||||
searchWorks: (q: string, mediaType?: string) => request<Work[]>("GET", "works/search", { query: { q, mediaType, limit: 8 } }),
|
||||
suggestions: (workId: string) => request<Suggestion[]>("GET", `works/${workId}/suggestions`),
|
||||
acceptSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/accept`),
|
||||
rejectSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/reject`),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { mediaColour } from "../format.ts";
|
||||
|
||||
/** A cover image, the media-type gradient when there is none, or the striped draft placeholder. */
|
||||
export function Cover({ url, mediaType, draft, size }: { url: string; mediaType: string; draft: boolean; size?: "large" | "small" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const cls = `cover${size ? ` ${size}` : ""}`;
|
||||
if (url && !failed) {
|
||||
return (
|
||||
<div className={cls}>
|
||||
<img src={url} alt="" loading="lazy" onError={() => setFailed(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (draft) return <div className={`${cls} is-draft`} aria-hidden="true" />;
|
||||
const c = mediaColour(mediaType);
|
||||
return <div className={cls} aria-hidden="true" style={{ background: `linear-gradient(160deg, ${c}, color-mix(in srgb, ${c} 25%, #0b0716))` }} />;
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { api, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
|
||||
import { orderEngagements, setCustomValue } from "../edits.ts";
|
||||
import {
|
||||
actorConnectionId,
|
||||
describeRating,
|
||||
draftIncoming,
|
||||
draftSettled,
|
||||
draftStartSave,
|
||||
engagementNouns,
|
||||
formatDateRange,
|
||||
formatPartialDate,
|
||||
formatRating,
|
||||
friendlyError,
|
||||
newDraft,
|
||||
ordinal,
|
||||
parsePartialDate,
|
||||
rollupLabel,
|
||||
statusLabel,
|
||||
timeAgo,
|
||||
type Draft,
|
||||
type PartialDate,
|
||||
} from "../format.ts";
|
||||
import { useEntryPatch, useSources } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { StarRating } from "./StarRating.tsx";
|
||||
|
||||
function Box({ title, id, action, wide, className, children }: { title: string; id: string; action?: ReactNode; wide?: boolean; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={`box${wide ? " wide" : ""}${className ? ` ${className}` : ""}`} aria-labelledby={id}>
|
||||
<div className="box-head">
|
||||
<h2 id={id}>{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reads / plays
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function EngagementsBox({
|
||||
entry,
|
||||
nouns,
|
||||
engagements,
|
||||
loading,
|
||||
}: {
|
||||
entry: EntryView;
|
||||
nouns: ReturnType<typeof engagementNouns>;
|
||||
engagements: Engagement[] | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["engagements", entry.id] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", entry.id] });
|
||||
};
|
||||
const add = useMutation({
|
||||
mutationFn: (b: { startedAt: PartialDate | null; finishedAt: PartialDate | null }) => api.addEngagement(entry.id, b),
|
||||
onSuccess: () => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) => setFormError(friendlyError(e)),
|
||||
});
|
||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
const remove = useMutation({
|
||||
mutationFn: api.deleteEngagement,
|
||||
onSuccess: () => (setRemoveError(null), refresh()),
|
||||
onError: (e) => setRemoveError(friendlyError(e)),
|
||||
});
|
||||
|
||||
function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
const read = (k: string) => {
|
||||
const raw = String(f.get(k) ?? "").trim();
|
||||
if (!raw) return { ok: true as const, v: null };
|
||||
const pd = parsePartialDate(raw);
|
||||
return pd ? { ok: true as const, v: pd } : { ok: false as const, v: null };
|
||||
};
|
||||
const s = read("startedAt");
|
||||
const d = read("finishedAt");
|
||||
if (!s.ok || !d.ok) return setFormError("Use a year, a month or a day: 2025, 2025-03 or 2025-03-14.");
|
||||
setFormError(null);
|
||||
add.mutate({ startedAt: s.v, finishedAt: d.v });
|
||||
}
|
||||
|
||||
// Oldest is the 1st; show newest first, like the mockup.
|
||||
const ordered = orderEngagements(engagements ?? []);
|
||||
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
|
||||
|
||||
return (
|
||||
<Box
|
||||
title={nouns.box}
|
||||
id="box-engagements"
|
||||
action={
|
||||
!adding && (
|
||||
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
|
||||
<Icon name="plus" /> {nouns.log}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading ? null : rows.length === 0 && !adding ? (
|
||||
<p className="empty-line">Nothing logged yet.</p>
|
||||
) : (
|
||||
rows.map(({ g, n }) => (
|
||||
<div className="r" key={g.id}>
|
||||
<span>{ordinal(n)} {nouns.one}</span>
|
||||
<span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
|
||||
<span className="tabular">{formatDateRange(g.startedAt, g.finishedAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost small x"
|
||||
aria-label={`Remove ${ordinal(n)} ${nouns.one}`}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(g.id)}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{removeError && <p className="error-text" role="alert">{removeError}</p>}
|
||||
{adding && (
|
||||
<form onSubmit={submit} aria-label={nouns.log}>
|
||||
<div className="two">
|
||||
<label className="field">
|
||||
<span>Started</span>
|
||||
<input className="input tabular" name="startedAt" placeholder="2025-03-14" inputMode="numeric" autoFocus aria-describedby="date-hint" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Finished</span>
|
||||
<input className="input tabular" name="finishedAt" placeholder="2025-03" inputMode="numeric" aria-describedby="date-hint" />
|
||||
</label>
|
||||
</div>
|
||||
<small className="hint" id="date-hint">A year, month or day is fine. Leave blank if you don't know.</small>
|
||||
{formError && <p className="error-text" role="alert">{formError}</p>}
|
||||
<div className="actions">
|
||||
<button className="btn primary small" disabled={add.isPending}>Save</button>
|
||||
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Your copy
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function formatPrice(c: Copy): string {
|
||||
if (c.priceCents === null) return "";
|
||||
const amount = c.priceCents / 100;
|
||||
if (!c.currency) return amount.toFixed(2);
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: "currency", currency: c.currency, minimumFractionDigits: c.priceCents % 100 ? 2 : 0 }).format(amount);
|
||||
} catch {
|
||||
return `${amount.toFixed(2)} ${c.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function CopyBox({ entry }: { entry: EntryView }) {
|
||||
const qc = useQueryClient();
|
||||
const copies = useQuery({ queryKey: ["copies", entry.workId], queryFn: () => api.copies(entry.workId) });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const add = useMutation({
|
||||
mutationFn: api.addCopy,
|
||||
onSuccess: () => {
|
||||
setAdding(false);
|
||||
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
|
||||
},
|
||||
onError: (e) => setFormError(friendlyError(e)),
|
||||
});
|
||||
|
||||
function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
const s = (k: string) => String(f.get(k) ?? "").trim() || null;
|
||||
const when = s("acquiredAt");
|
||||
const acquiredAt = when ? parsePartialDate(when) : null;
|
||||
if (when && !acquiredAt) return setFormError("Use a year, a month or a day for when: 2025, 2025-02 or 2025-02-14.");
|
||||
const price = s("price");
|
||||
const priceNum = price ? Number(price.replace(",", ".")) : null;
|
||||
if (price && (priceNum === null || Number.isNaN(priceNum))) return setFormError("Price should be a number, like 14 or 14.99.");
|
||||
const currency = s("currency")?.toUpperCase() ?? null;
|
||||
if (currency && !/^[A-Z]{3}$/.test(currency)) return setFormError("Currency is a three-letter code, like GBP or EUR.");
|
||||
setFormError(null);
|
||||
add.mutate({
|
||||
workId: entry.workId,
|
||||
format: s("format"),
|
||||
acquiredFrom: s("acquiredFrom"),
|
||||
acquiredAt,
|
||||
priceCents: priceNum === null ? null : Math.round(priceNum * 100),
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
const list = copies.data ?? [];
|
||||
return (
|
||||
<Box
|
||||
title="Your copy"
|
||||
id="box-copy"
|
||||
action={
|
||||
!adding && list.length > 0 && (
|
||||
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
|
||||
<Icon name="plus" /> Another
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{copies.isPending ? null : list.length === 0 && !adding ? (
|
||||
<div className="r">
|
||||
<span>You haven't recorded a copy.</span>
|
||||
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>Add your copy</button>
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => {
|
||||
const when = [formatPartialDate(c.acquiredAt), formatPrice(c)].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div className="copy" key={c.id}>
|
||||
{c.acquiredFrom && <div className="r"><span>Bought</span><span>{c.acquiredFrom}</span></div>}
|
||||
{when && <div className="r"><span>When / price</span><span className="tabular">{when}</span></div>}
|
||||
{c.format && <div className="r"><span>Format</span><span>{c.format}</span></div>}
|
||||
{!c.acquiredFrom && !when && !c.format && <div className="r"><span>Owned</span><span>No details yet</span></div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{adding && (
|
||||
<form onSubmit={submit} aria-label="Add your copy">
|
||||
<div className="two">
|
||||
<label className="field"><span>Bought at</span><input className="input" name="acquiredFrom" placeholder="Waterstones, Leeds" autoFocus /></label>
|
||||
<label className="field"><span>Format</span><input className="input" name="format" placeholder="Hardback" /></label>
|
||||
</div>
|
||||
<div className="two">
|
||||
<label className="field"><span>When</span><input className="input tabular" name="acquiredAt" placeholder="2025-02" /></label>
|
||||
<div className="two" style={{ gridTemplateColumns: "1fr 72px" }}>
|
||||
<label className="field"><span>Price</span><input className="input tabular" name="price" inputMode="decimal" placeholder="14" /></label>
|
||||
<label className="field"><span>Currency</span><input className="input" name="currency" placeholder="GBP" maxLength={3} /></label>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p className="error-text" role="alert">{formError}</p>}
|
||||
<div className="actions">
|
||||
<button className="btn primary small" disabled={add.isPending}>Save</button>
|
||||
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Attachments
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function AttachmentsBox({ entry }: { entry: EntryView }) {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ["attachments", entry.id], queryFn: () => api.attachments("entry", entry.id) });
|
||||
const items = list.data ?? [];
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const upload = useMutation({
|
||||
mutationFn: (file: File) => api.upload(file, entry.id),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
void qc.invalidateQueries({ queryKey: ["attachments", entry.id] });
|
||||
},
|
||||
onError: (e) => setError(friendlyError(e)),
|
||||
});
|
||||
return (
|
||||
<Box title="Attachments" id="box-attachments">
|
||||
<div className="thumbs">
|
||||
{items.map((a) => {
|
||||
const url = `/api/v1/attachments/${a.id}`;
|
||||
const image = a.mime.startsWith("image/");
|
||||
return (
|
||||
<a
|
||||
key={a.id}
|
||||
className="thumb"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={a.filename}
|
||||
aria-label={`Open ${a.filename}`}
|
||||
style={image ? { backgroundImage: `url("${url}")` } : undefined}
|
||||
>
|
||||
{!image && <Icon name="file" />}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<label className="thumb upload" title="Add screenshots, photos or receipts">
|
||||
<span className="sr-only">Upload an attachment</span>
|
||||
{upload.isPending ? "…" : <Icon name="upload" />}
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept="image/*,application/pdf,text/plain"
|
||||
disabled={upload.isPending}
|
||||
onChange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f) upload.mutate(f);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
{list.error && <p className="error-text" role="alert">{friendlyError(list.error)}</p>}
|
||||
{items.length === 0 && !error && !list.isPending && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Linked sources
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function SourcesBox({ entry, engagements }: { entry: EntryView; engagements: Engagement[] }) {
|
||||
const sources = useSources();
|
||||
// A source is linked when it has written any field of the entry or its reads.
|
||||
const ids = new Set<string>();
|
||||
for (const fs of [entry.fieldSources, ...engagements.map((g) => g.fieldSources)]) {
|
||||
for (const actor of Object.values(fs ?? {})) {
|
||||
const id = actorConnectionId(actor);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
const linked = (sources.data ?? []).filter((s) => ids.has(s.id));
|
||||
return (
|
||||
<Box title="Linked sources" id="box-sources">
|
||||
{linked.length === 0 ? (
|
||||
<p className="empty-line">No source syncs this entry. Everything here is yours.</p>
|
||||
) : (
|
||||
linked.map((s) => (
|
||||
<div className="src" key={s.id}>
|
||||
<i className={s.status === "active" ? "" : "paused"} aria-hidden="true" />
|
||||
{s.name} · {s.status !== "active" ? "paused" : s.lastRunAt ? `synced ${timeAgo(s.lastRunAt)}` : "not synced yet"}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Custom fields
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
|
||||
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
|
||||
const patch = useEntryPatch(entry.id);
|
||||
const scale = useRatingScale();
|
||||
if (!defs.data?.length) return null;
|
||||
const save = (def: CustomFieldDef, value: unknown) =>
|
||||
patch.edit((cur) => {
|
||||
const next = setCustomValue(cur.customFields, def.id, value);
|
||||
return JSON.stringify(next) === JSON.stringify(cur.customFields) ? null : { customFields: next };
|
||||
});
|
||||
return (
|
||||
<Box title="Details" id="box-custom" wide>
|
||||
<div className="custom">
|
||||
{defs.data.map((d) => (
|
||||
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
|
||||
))}
|
||||
</div>
|
||||
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomField({ def, value, onSave }: { def: CustomFieldDef; value: unknown; onSave: (v: unknown) => void }) {
|
||||
const scale = useRatingScale();
|
||||
const str = value === undefined || value === null ? "" : String(value);
|
||||
const text = (multiline: boolean, inputType = "text") => (
|
||||
<label className="field">
|
||||
<span>{def.name}</span>
|
||||
{multiline ? (
|
||||
<textarea className="textarea" defaultValue={str} key={str} rows={3} onBlur={(e) => onSave(e.target.value.trim())} />
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={inputType}
|
||||
defaultValue={str}
|
||||
key={str}
|
||||
placeholder={def.type === "date" ? "2025-03-14" : undefined}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (def.type === "number") onSave(v === "" ? null : Number(v));
|
||||
else if (def.type === "date") onSave(v === "" || parsePartialDate(v) ? v : str);
|
||||
else onSave(v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
switch (def.type) {
|
||||
case "long_text":
|
||||
return text(true);
|
||||
case "number":
|
||||
return text(false, "number");
|
||||
case "url":
|
||||
return text(false, "url");
|
||||
case "boolean":
|
||||
return (
|
||||
<label className="inline">
|
||||
<input type="checkbox" checked={value === true} onChange={(e) => onSave(e.target.checked ? true : null)} />
|
||||
{def.name}
|
||||
</label>
|
||||
);
|
||||
case "single_select":
|
||||
return (
|
||||
<label className="field">
|
||||
<span>{def.name}</span>
|
||||
<select className="select" value={str} onChange={(e) => onSave(e.target.value || null)}>
|
||||
<option value="">—</option>
|
||||
{def.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
case "multi_select": {
|
||||
const cur = Array.isArray(value) ? (value as string[]) : [];
|
||||
return (
|
||||
<fieldset className="field" style={{ border: 0 }}>
|
||||
<legend className="hint" style={{ color: "var(--text-muted)", fontWeight: 500, marginBottom: 5 }}>{def.name}</legend>
|
||||
<div className="checks">
|
||||
{def.options.map((o) => (
|
||||
<label key={o} className="inline">
|
||||
<input type="checkbox" checked={cur.includes(o)} onChange={(e) => onSave(e.target.checked ? [...cur, o] : cur.filter((x) => x !== o))} />
|
||||
{o}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
case "rating":
|
||||
return (
|
||||
<div className="field">
|
||||
<span>{def.name}</span>
|
||||
<StarRating value={typeof value === "number" ? value : null} scale={scale} label={def.name} onChange={onSave} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return text(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Children (seasons, episodes, volumes)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {
|
||||
const scale = useRatingScale();
|
||||
const children = useQuery({
|
||||
queryKey: ["entries", { parentId: entry.workId }],
|
||||
queryFn: () => api.entries({ parentId: entry.workId, limit: 200 }),
|
||||
});
|
||||
const label = rollupLabel(type, entry.childSummary ? { ...entry.childSummary, completed: 0 } : null) ?? "Parts";
|
||||
const title = label.replace(/^\d+\s+/, "");
|
||||
return (
|
||||
<Box title={title.charAt(0).toUpperCase() + title.slice(1)} id="box-children" wide>
|
||||
<ul className="children">
|
||||
{(children.data?.items ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link href={`/entries/${c.id}`}>
|
||||
<span className="t">
|
||||
{c.work.title}
|
||||
<span className="s">{statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)}</span>
|
||||
</span>
|
||||
{c.rating !== null ? (
|
||||
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
|
||||
) : (
|
||||
<span className="s">Not rated</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Review
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function ReviewBox({ entry }: { entry: EntryView }) {
|
||||
const [saved, setSaved] = useState(false);
|
||||
// Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows
|
||||
// up here unless the user has typed since; a failed save keeps what they typed.
|
||||
const [draft, setDraft] = useState<Draft>(() => newDraft(entry.review ?? ""));
|
||||
// Saves in flight, oldest first. The entry-patch scope runs them serially, so they settle in order.
|
||||
const saves = useRef<{ was: string; stored: string }[]>([]);
|
||||
const patch = useEntryPatch(entry.id, {
|
||||
onSettled: (_p, error) => {
|
||||
const save = saves.current.shift();
|
||||
if (!save) return;
|
||||
setDraft((d) => draftSettled(d, save, error === null));
|
||||
setSaved(error === null && saves.current.length === 0);
|
||||
},
|
||||
});
|
||||
useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]);
|
||||
const save = () => {
|
||||
const next = draft.text.trim() === "" ? null : draft.text;
|
||||
const stored = next ?? "";
|
||||
if (stored === draft.synced && stored === (entry.review ?? "")) return;
|
||||
const s = { was: draft.synced, stored };
|
||||
saves.current.push(s);
|
||||
setDraft((d) => draftStartSave(d, s));
|
||||
patch.mutate({ review: next });
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
title="Your review"
|
||||
id="box-review"
|
||||
wide
|
||||
className="review"
|
||||
action={<span className="saved" aria-live="polite">{patch.isPending ? "Saving…" : saved ? "Saved" : ""}</span>}
|
||||
>
|
||||
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
|
||||
<textarea
|
||||
id="review-text"
|
||||
value={draft.text}
|
||||
placeholder="What stayed with you? Markdown works here."
|
||||
onChange={(e) => {
|
||||
const text = e.target.value;
|
||||
setDraft((d) => ({ ...d, text }));
|
||||
setSaved(false);
|
||||
}}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
|
||||
}}
|
||||
/>
|
||||
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ApiError, api, type EntryView } from "../api.ts";
|
||||
import { addTag, removeTag } from "../edits.ts";
|
||||
import { engagementNouns, formatPartialDate, friendlyError, mediaTypeLabel, shortDate, STATUSES, statusLabel } from "../format.ts";
|
||||
import { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
import { Cover } from "./Cover.tsx";
|
||||
import { AttachmentsBox, ChildrenBox, CopyBox, CustomFieldsBox, EngagementsBox, ReviewBox, SourcesBox } from "./DetailBoxes.tsx";
|
||||
import { HistoryDrawer } from "./HistoryDrawer.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { LinkToCatalogue } from "./LinkToCatalogue.tsx";
|
||||
import { StarRating } from "./StarRating.tsx";
|
||||
|
||||
const TIMES = ["", "once", "twice", "three times"];
|
||||
|
||||
export function EntryDetail({ id }: { id: string }) {
|
||||
const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
|
||||
if (entry.isPending) return <div className="detail" aria-busy="true" />;
|
||||
// A failed background refetch (server briefly down) keeps showing the cached entry, so nothing the
|
||||
// user is typing gets unmounted; only a first load that fails shows the error screen.
|
||||
if (!entry.data) {
|
||||
const gone = entry.error instanceof ApiError && entry.error.status === 404;
|
||||
return (
|
||||
<div className="detail-empty" role="alert">
|
||||
<p>
|
||||
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
|
||||
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Detail entry={entry.data} />;
|
||||
}
|
||||
|
||||
function Detail({ entry }: { entry: EntryView }) {
|
||||
const scale = useRatingScale();
|
||||
const patch = useEntryPatch(entry.id);
|
||||
const [, navigate] = useLocation();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const historyBtn = useRef<HTMLButtonElement>(null);
|
||||
const type = entry.mediaTypeOverride ?? entry.work.mediaType;
|
||||
const nouns = engagementNouns(type);
|
||||
|
||||
const engagements = useQuery({ queryKey: ["engagements", entry.id], queryFn: () => api.engagements(entry.id) });
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const engagementIds = new Set((engagements.data ?? []).map((g) => g.id));
|
||||
const pending = changesFor(changes.data?.items, entry.id, engagementIds);
|
||||
|
||||
// "Book · Read twice · last finished 14 Sep 2026"
|
||||
const finished = (engagements.data ?? []).filter((g) => g.finishedAt && g.finishedAt.precision !== "unknown");
|
||||
const meta = [mediaTypeLabel(type)];
|
||||
if (entry.work.year) meta.push(String(entry.work.year));
|
||||
if (finished.length) {
|
||||
meta.push(`${nouns.past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
|
||||
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
|
||||
meta.push(`last finished ${formatPartialDate(last)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="detail" aria-labelledby="entry-title">
|
||||
<Link href="/" className="btn ghost back"><Icon name="back" /> Library</Link>
|
||||
<header className="det-head">
|
||||
<Cover url={entry.work.coverUrl} mediaType={type} draft={entry.draft} size="large" />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 id="entry-title">
|
||||
{entry.work.title}
|
||||
{entry.draft && <span className="draft">Draft</span>}
|
||||
</h1>
|
||||
<p className="m">{meta.join(" · ")}</p>
|
||||
<div className="det-controls">
|
||||
<StarRating value={entry.rating} scale={scale} label="Your rating" onChange={(rating) => patch.mutate({ rating })} />
|
||||
<label>
|
||||
<span className="sr-only">Status</span>
|
||||
<select className="select" value={entry.status} onChange={(e) => patch.mutate({ status: e.target.value as EntryView["status"] })}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{statusLabel(s, type)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="det-tools">
|
||||
<button
|
||||
ref={historyBtn}
|
||||
type="button"
|
||||
className="btn ghost small"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls="history-drawer"
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="history" /> History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tags
|
||||
tags={entry.tags}
|
||||
onAdd={(t) => patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))}
|
||||
onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))}
|
||||
/>
|
||||
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="alert" role="status">
|
||||
<p>
|
||||
<b>{whoChanged(pending, sources.data)} changed this {mediaTypeLabel(type).toLowerCase()}.</b>{" "}
|
||||
{pending.length === 1 ? `On ${shortDate(pending[0]!.createdAt)}.` : `${pending.length} changes are waiting for you.`} Your rating,{" "}
|
||||
{nouns.box.toLowerCase()} and review here are unchanged.
|
||||
</p>
|
||||
<button type="button" className="btn" onClick={() => navigate(`/changes?entry=${entry.id}`)}>See what changed</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.draft && <LinkToCatalogue entry={entry} />}
|
||||
|
||||
<div className="det-grid">
|
||||
<EngagementsBox entry={entry} nouns={nouns} engagements={engagements.data} loading={engagements.isPending} />
|
||||
<CopyBox entry={entry} />
|
||||
<AttachmentsBox entry={entry} />
|
||||
<SourcesBox entry={entry} engagements={engagements.data ?? []} />
|
||||
<CustomFieldsBox entry={entry} type={type} />
|
||||
{entry.childSummary && <ChildrenBox entry={entry} type={type} />}
|
||||
<ReviewBox entry={entry} />
|
||||
</div>
|
||||
|
||||
{historyOpen && (
|
||||
<HistoryDrawer
|
||||
entry={entry}
|
||||
onClose={() => {
|
||||
setHistoryOpen(false);
|
||||
historyBtn.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function whoChanged(pending: { connectionId: string | null }[], sources: { id: string; name: string }[] | undefined): string {
|
||||
const ids = new Set(pending.map((p) => p.connectionId));
|
||||
if (ids.size === 1) {
|
||||
const name = sources?.find((s) => s.id === [...ids][0])?.name;
|
||||
if (name) return name;
|
||||
}
|
||||
return ids.size > 1 ? "Your sources" : "A source";
|
||||
}
|
||||
|
||||
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
function add() {
|
||||
if (draft.trim()) onAdd(draft);
|
||||
setDraft("");
|
||||
}
|
||||
return (
|
||||
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
||||
{tags.map((t) => (
|
||||
<li key={t} className="pill">
|
||||
{t}
|
||||
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<label>
|
||||
<span className="sr-only">Add a tag</span>
|
||||
<input
|
||||
className="tag-input"
|
||||
placeholder="+ tag"
|
||||
value={draft}
|
||||
maxLength={100}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
onBlur={add}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api, type EntryView, type HistoryRecord } from "../api.ts";
|
||||
import { actorConnectionId, formatPartialDate, formatRating, friendlyError, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
|
||||
import { useSources } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
status: "Status",
|
||||
rating: "Rating",
|
||||
review: "Review",
|
||||
privateNotes: "Private notes",
|
||||
favorite: "Favourite",
|
||||
tags: "Tags",
|
||||
customFields: "Details",
|
||||
visibility: "Visibility",
|
||||
overrides: "Title and cover",
|
||||
mediaTypeOverride: "Media type",
|
||||
workId: "Catalogue link",
|
||||
deletedAt: "Removed",
|
||||
};
|
||||
const SKIP = new Set(["id", "userId", "fieldSources", "createdAt", "updatedAt"]);
|
||||
|
||||
/** History of an entry, newest first, with "Restore" to put back what a change replaced. */
|
||||
export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const scale = useRatingScale();
|
||||
const sources = useSources();
|
||||
const closeBtn = useRef<HTMLButtonElement>(null);
|
||||
const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
|
||||
const restore = useMutation({
|
||||
// Same scope as the entry's PATCHes (useEntryPatch), so a restore never races a pending blur-save.
|
||||
scope: { id: `entry-patch:${entry.id}` },
|
||||
mutationFn: api.restore,
|
||||
onSuccess: () => {
|
||||
for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
closeBtn.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const who = (actor: string) => {
|
||||
if (actor === "user") return "You";
|
||||
if (actor.startsWith("token:")) return `App token “${actor.slice(6)}”`;
|
||||
const id = actorConnectionId(actor);
|
||||
return sources.data?.find((s) => s.id === id)?.name ?? actor.split(":")[1] ?? "A source";
|
||||
};
|
||||
const show = (field: string, v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "empty";
|
||||
if (field === "rating") return formatRating(v as number, scale);
|
||||
if (field === "status") return statusLabel(v as Status, entry.mediaTypeOverride ?? entry.work.mediaType);
|
||||
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
|
||||
if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
|
||||
if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
|
||||
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||
const s = String(v);
|
||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||
};
|
||||
const diffs = (h: HistoryRecord) =>
|
||||
[...new Set([...Object.keys(h.before ?? {}), ...Object.keys(h.after ?? {})])]
|
||||
.filter((k) => !SKIP.has(k) && JSON.stringify(h.before?.[k]) !== JSON.stringify(h.after?.[k]))
|
||||
.map((k) => ({ k, before: show(k, h.before?.[k]), after: show(k, h.after?.[k]) }));
|
||||
|
||||
return (
|
||||
<aside className="drawer" id="history-drawer" role="dialog" aria-modal="false" aria-labelledby="history-title">
|
||||
<header>
|
||||
<h2 id="history-title">History</h2>
|
||||
<button ref={closeBtn} type="button" className="btn ghost small" onClick={onClose} aria-label="Close history">
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</header>
|
||||
{history.isPending ? null : history.error ? (
|
||||
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{friendlyError(history.error)}</p>
|
||||
) : (
|
||||
<ol>
|
||||
{history.data.map((h) => {
|
||||
const d = diffs(h);
|
||||
const created = Object.keys(h.before ?? {}).length === 0;
|
||||
return (
|
||||
<li key={h.id}>
|
||||
<div className="when">
|
||||
<span><span className="who">{who(h.actor)}</span> · {timeAgo(h.createdAt)}</span>
|
||||
{!created && d.length > 0 && (
|
||||
<button type="button" className="btn small" disabled={restore.isPending} onClick={() => restore.mutate(h.id)}>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{created ? (
|
||||
<p className="hint">Added to your library.</p>
|
||||
) : d.length === 0 ? (
|
||||
<p className="hint">No visible change.</p>
|
||||
) : (
|
||||
<dl>
|
||||
{d.map(({ k, before, after }) => (
|
||||
<div key={k} style={{ display: "contents" }}>
|
||||
<dt>{LABELS[k] ?? k}</dt>
|
||||
<dd><s>{before}</s><span className="arrow" aria-label="changed to">→</span>{after}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{friendlyError(restore.error)}</p>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** A small authored icon set: 24px grid, 1.75 stroke, round caps. */
|
||||
const PATHS = {
|
||||
plus: "M12 5v14M5 12h14",
|
||||
search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4",
|
||||
x: "M6 6l12 12M18 6L6 18",
|
||||
history: "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2",
|
||||
back: "M15 18l-6-6 6-6",
|
||||
upload: "M12 16V4M7 9l5-5 5 5M5 20h14",
|
||||
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
|
||||
file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function Icon({ name }: { name: IconName }) {
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { api, type EntryView, type Work } from "../api.ts";
|
||||
import { friendlyError, mediaTypeLabel } from "../format.ts";
|
||||
import { useDebounced } from "../hooks.ts";
|
||||
import { Cover } from "./Cover.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
|
||||
/** Drafts only: suggested matches and a catalogue search. Linking keeps the user's rating, reads and review. */
|
||||
export function LinkToCatalogue({ entry }: { entry: EntryView }) {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const [q, setQ] = useState("");
|
||||
const term = useDebounced(q.trim());
|
||||
const suggestions = useQuery({ queryKey: ["suggestions", entry.workId], queryFn: () => api.suggestions(entry.workId) });
|
||||
const candidates = useQueries({
|
||||
queries: (suggestions.data ?? []).slice(0, 5).map((s) => ({ queryKey: ["work", s.candidateWorkId], queryFn: () => api.work(s.candidateWorkId), staleTime: 300_000 })),
|
||||
});
|
||||
const search = useQuery({
|
||||
queryKey: ["works-search", term],
|
||||
queryFn: () => api.searchWorks(term),
|
||||
enabled: term.length >= 2,
|
||||
});
|
||||
|
||||
const done = (newId?: string) => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["entry", entry.id] });
|
||||
void qc.invalidateQueries({ queryKey: ["suggestions", entry.workId] });
|
||||
if (newId && newId !== entry.id) navigate(`/entries/${newId}`, { replace: true });
|
||||
};
|
||||
const move = useMutation({ mutationFn: (workId: string) => api.moveEntry(entry.id, workId), onSuccess: (e) => done(e.id) });
|
||||
const accept = useMutation({ mutationFn: api.acceptSuggestion, onSuccess: () => done() });
|
||||
const reject = useMutation({ mutationFn: api.rejectSuggestion, onSuccess: () => done() });
|
||||
const busy = move.isPending || accept.isPending || reject.isPending;
|
||||
const error = move.error ?? accept.error ?? reject.error;
|
||||
|
||||
const row = (w: Work, actions: ReactNode) => (
|
||||
<li key={w.id}>
|
||||
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div>{w.title}</div>
|
||||
<div className="m">{[mediaTypeLabel(w.media_type), w.year, w.creators?.[0]?.name].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="acts">{actions}</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
const results = (search.data ?? []).filter((w) => w.id !== entry.workId);
|
||||
return (
|
||||
<section className="box link-box" aria-labelledby="link-title">
|
||||
<div className="box-head">
|
||||
<h2 id="link-title"><Icon name="link" /> Link to catalogue</h2>
|
||||
</div>
|
||||
<p>This is a draft. Link it to a catalogue entry to fill in the cover and details. Your rating, reads and review stay as they are.</p>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<h3 className="sub">Suggested</h3>
|
||||
<ul className="results">
|
||||
{suggestions.data!.slice(0, 5).map((s, i) => {
|
||||
const w = candidates[i]?.data;
|
||||
if (!w) return null;
|
||||
return row(
|
||||
w,
|
||||
<>
|
||||
<button type="button" className="btn primary small" disabled={busy} onClick={() => accept.mutate(s.id)}>Link</button>
|
||||
<button type="button" className="btn ghost small" disabled={busy} onClick={() => reject.mutate(s.id)}>Not this</button>
|
||||
</>,
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="search">
|
||||
<Icon name="search" />
|
||||
<label htmlFor="catalogue-search" className="sr-only">Search the catalogue</label>
|
||||
<input
|
||||
id="catalogue-search"
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Search the catalogue…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{term.length >= 2 && !search.isPending && (
|
||||
results.length === 0 ? (
|
||||
<p className="hint">Nothing in the catalogue matches “{term}”. Connect a source to bring in more.</p>
|
||||
) : (
|
||||
<ul className="results" aria-label="Search results">
|
||||
{results.map((w) =>
|
||||
row(w, <button type="button" className="btn primary small" disabled={busy} onClick={() => move.mutate(w.id)}>Link</button>),
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { api } from "../api.ts";
|
||||
import { useMe, usePendingChanges, useSources } from "../hooks.ts";
|
||||
import { resetLibraryState } from "../routes/library.tsx";
|
||||
|
||||
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Link href={href} className={`nav${className ? ` ${className}` : ""}`} aria-current={active ? "page" : undefined}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sidebar navigation plus the main area. Stacks into a top bar on narrow screens. */
|
||||
export function Shell({ children }: { children: ReactNode }) {
|
||||
const [path, navigate] = useLocation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const pending = changes.data?.items.length ?? 0;
|
||||
const at = (p: string) => path === p || path.startsWith(`${p}/`);
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
// The session may already be gone; signing out locally is still right.
|
||||
}
|
||||
qc.clear();
|
||||
resetLibraryState();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<Link href="/" className="logo">STTracker</Link>
|
||||
<nav aria-label="Main">
|
||||
<NavLink href="/" active={path === "/" || at("/entries")}>Library</NavLink>
|
||||
<NavLink href="/log" active={at("/log")}>Log</NavLink>
|
||||
<NavLink href="/lists" active={at("/lists")}>Lists</NavLink>
|
||||
<NavLink href="/owned" active={at("/owned")}>Owned</NavLink>
|
||||
<NavLink href="/changes" active={at("/changes")}>
|
||||
Changes
|
||||
{pending > 0 && (
|
||||
<span className="count">
|
||||
{changes.data?.nextCursor ? `${pending}+` : pending}
|
||||
<span className="sr-only"> waiting</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<h2 className="grp">Sources</h2>
|
||||
<nav aria-label="Sources" className="src-nav">
|
||||
{(sources.data ?? []).map((s) => (
|
||||
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
|
||||
<span className="name">{s.name}</span>
|
||||
<span className={`dot${s.status === "active" ? "" : " paused"}`} role="img" aria-label={s.status === "active" ? "Active" : "Paused"} />
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink href="/sources" active={path === "/sources"}>
|
||||
{sources.data?.length ? "All sources" : "Connect a source"}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<nav aria-label="Account" style={{ marginTop: 14 }}>
|
||||
<NavLink href="/settings" active={at("/settings")}>Settings</NavLink>
|
||||
</nav>
|
||||
<div className="side-foot">
|
||||
{me.data && <span className="who">{me.data.displayName || me.data.username}</span>}
|
||||
<button type="button" onClick={() => void signOut()}>Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { describeRating, ratingStep, snapRating, type RatingScale } from "../format.ts";
|
||||
|
||||
const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
|
||||
|
||||
/**
|
||||
* Editable rating in the user's scale. Stars are a slider: arrows step (half a star on "5half"),
|
||||
* Home/End jump, Delete clears. The 10 and 100 point scales use a number field.
|
||||
*/
|
||||
export function StarRating({
|
||||
value,
|
||||
scale,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
scale: RatingScale;
|
||||
label: string;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
// Arrow keys step a local value; it is saved once the user pauses (or leaves), not on every step.
|
||||
const [stepping, setStepping] = useState<{ v: number | null } | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clipBase = useId();
|
||||
const step = ratingStep(scale);
|
||||
// Leaving mid-step (navigating to another entry) still saves the stepped value.
|
||||
const latest = useRef({ stepping, value, onChange });
|
||||
latest.current = { stepping, value, onChange };
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
const { stepping: st, value: v, onChange: save } = latest.current;
|
||||
if (st && st.v !== v) save(st.v);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (scale === "10" || scale === "100") {
|
||||
const max = scale === "10" ? 10 : 100;
|
||||
const shown = value === null ? "" : String(scale === "10" ? snapRating(value, scale) / 10 : value);
|
||||
return (
|
||||
<label className="rating-number">
|
||||
<span className="sr-only">{label}</span>
|
||||
<input
|
||||
className="input tabular"
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
step={scale === "10" ? 0.5 : 1}
|
||||
defaultValue={shown}
|
||||
key={shown}
|
||||
onBlur={(e) => {
|
||||
const raw = e.currentTarget.value.trim();
|
||||
if (raw === "") return value !== null && onChange(null);
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return;
|
||||
const v = snapRating(scale === "10" ? n * 10 : n, scale);
|
||||
if (v !== value) onChange(v);
|
||||
}}
|
||||
/>
|
||||
<span>/ {max}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (v: number | null) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
setStepping(null);
|
||||
if (v !== value) onChange(v === null ? null : snapRating(v, scale));
|
||||
};
|
||||
const flush = () => stepping && set(stepping.v);
|
||||
const current = stepping ? stepping.v : value;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const cur = current ?? 0;
|
||||
const map: Record<string, number | null> = {
|
||||
ArrowRight: Math.min(100, cur + step),
|
||||
ArrowUp: Math.min(100, cur + step),
|
||||
ArrowLeft: Math.max(0, cur - step),
|
||||
ArrowDown: Math.max(0, cur - step),
|
||||
Home: 0,
|
||||
End: 100,
|
||||
Delete: null,
|
||||
Backspace: null,
|
||||
};
|
||||
if (!(e.key in map)) return;
|
||||
e.preventDefault();
|
||||
const next = map[e.key]!;
|
||||
setStepping({ v: next });
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => set(next), 500);
|
||||
};
|
||||
const valueAt = (e: PointerEvent<HTMLSpanElement>, i: number) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const half = scale === "5half" && e.clientX - r.left < r.width / 2;
|
||||
return (i + (half ? 0.5 : 1)) * 20;
|
||||
};
|
||||
const shown = hover ?? current ?? 0;
|
||||
|
||||
return (
|
||||
<span className="rating-wrap" style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
<span
|
||||
className="rating"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={current ?? undefined}
|
||||
aria-valuetext={describeRating(current, scale)}
|
||||
onKeyDown={onKey}
|
||||
onBlur={flush}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const fill = Math.max(0, Math.min(1, shown / 20 - i));
|
||||
const id = `${clipBase}-${i}`;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onPointerMove={(e) => setHover(valueAt(e, i))}
|
||||
onClick={(e) => {
|
||||
const v = valueAt(e as unknown as PointerEvent<HTMLSpanElement>, i);
|
||||
set(v === value ? null : v);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={id}>
|
||||
<rect x="0" y="0" width={24 * fill} height="24" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path className="empty" d={STAR} />
|
||||
{fill > 0 && <path className="full" d={STAR} clipPath={`url(#${id})`} />}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{current !== null && (
|
||||
<button type="button" className="rating-clear" onClick={() => set(null)}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Next-value builders for entry edits. Callers build from the latest cached entry (not a render's
|
||||
* props), so quick successive edits (two tag adds) compose instead of overwriting each other.
|
||||
*/
|
||||
import type { PartialDate } from "./format.ts";
|
||||
|
||||
export function addTag(tags: string[], tag: string): string[] {
|
||||
const t = tag.trim();
|
||||
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) return tags;
|
||||
return [...tags, t];
|
||||
}
|
||||
|
||||
export function removeTag(tags: string[], tag: string): string[] {
|
||||
return tags.filter((x) => x !== tag);
|
||||
}
|
||||
|
||||
/** Set a custom field value by definition id; null, "" and [] remove it. */
|
||||
export function setCustomValue(fields: Record<string, unknown>, id: string, value: unknown): Record<string, unknown> {
|
||||
const next = { ...fields };
|
||||
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) delete next[id];
|
||||
else next[id] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The optimistic view of an entry after a PATCH. */
|
||||
export function applyEntryPatch<T extends object>(entry: T, patch: Partial<T>): T {
|
||||
return { ...entry, ...patch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads/plays oldest first: by `finishedAt ?? startedAt` (partial dates compare as strings), then by
|
||||
* when they were logged. Undated ones count as oldest.
|
||||
*/
|
||||
export function orderEngagements<G extends { createdAt: string; startedAt: PartialDate | null; finishedAt: PartialDate | null }>(list: G[]): G[] {
|
||||
const key = (g: G) => (g.finishedAt?.value || g.startedAt?.value || "");
|
||||
return [...list].sort((a, b) => {
|
||||
const ka = key(a);
|
||||
const kb = key(b);
|
||||
if (ka !== kb) return ka < kb ? -1 : 1;
|
||||
return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,314 @@
|
||||
/** Display helpers. Pure functions: no DOM, no network. Partial dates never go through `Date`. */
|
||||
|
||||
export type Precision = "day" | "month" | "year" | "unknown";
|
||||
export interface PartialDate {
|
||||
value: string;
|
||||
precision: Precision;
|
||||
}
|
||||
|
||||
/** How ratings are shown and entered. Stored ratings are always integers 0–100. */
|
||||
export type RatingScale = "5half" | "5" | "10" | "100";
|
||||
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** Size of one step of the scale, in 0–100 units. */
|
||||
export function ratingStep(scale: RatingScale): number {
|
||||
return scale === "5half" ? 10 : scale === "5" ? 20 : scale === "10" ? 5 : 1;
|
||||
}
|
||||
|
||||
/** Snap a 0–100 value to the scale's nearest step. */
|
||||
export function snapRating(value: number, scale: RatingScale): number {
|
||||
const step = ratingStep(scale);
|
||||
return Math.min(100, Math.max(0, Math.round(value / step) * step));
|
||||
}
|
||||
|
||||
/** `formatRating(90, "5half")` is `"★★★★½"`; null is `""`. */
|
||||
export function formatRating(value: number | null | undefined, scale: RatingScale): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const v = snapRating(value, scale);
|
||||
if (scale === "10") return `${v / 10}/10`;
|
||||
if (scale === "100") return `${v}/100`;
|
||||
const stars = v / 20;
|
||||
const full = Math.floor(stars);
|
||||
const half = stars - full >= 0.5;
|
||||
return "★".repeat(full) + (half ? "½" : "") + "☆".repeat(5 - full - (half ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Screen-reader text for a rating: "4½ stars", "9 out of 10", "Not rated". */
|
||||
export function describeRating(value: number | null | undefined, scale: RatingScale): string {
|
||||
if (value === null || value === undefined) return "Not rated";
|
||||
const v = snapRating(value, scale);
|
||||
if (scale === "10") return `${v / 10} out of 10`;
|
||||
if (scale === "100") return `${v} out of 100`;
|
||||
const stars = v / 20;
|
||||
const whole = Math.floor(stars);
|
||||
const label = stars % 1 ? `${whole === 0 ? "" : whole}½` : String(whole);
|
||||
return `${label} ${stars === 1 ? "star" : "stars"}`;
|
||||
}
|
||||
|
||||
/** `{value:"2025-03",precision:"month"}` is `"Mar 2025"`. Unknown or missing dates are `""`. */
|
||||
export function formatPartialDate(pd: PartialDate | null | undefined): string {
|
||||
if (!pd || pd.precision === "unknown" || !pd.value) return "";
|
||||
const y = pd.value.slice(0, 4);
|
||||
if (pd.precision === "year") return y;
|
||||
const month = MONTHS[Number(pd.value.slice(5, 7)) - 1] ?? "";
|
||||
if (pd.precision === "month") return `${month} ${y}`;
|
||||
return `${Number(pd.value.slice(8, 10))} ${month} ${y}`;
|
||||
}
|
||||
|
||||
const isExact = (pd: PartialDate | null | undefined) => !pd || pd.precision === "day";
|
||||
|
||||
/**
|
||||
* A read/play's dates: "2 Sep – 14 Sep 2026", "Mar 2025 (no exact date)", "Started 2 Sep 2026".
|
||||
* Anything coarser than a day is flagged so a month never passes for a precise date.
|
||||
*/
|
||||
export function formatDateRange(start: PartialDate | null | undefined, end: PartialDate | null | undefined): string {
|
||||
const s = formatPartialDate(start);
|
||||
const e = formatPartialDate(end);
|
||||
const suffix = isExact(start) && isExact(end) ? "" : " (no exact date)";
|
||||
if (!s && !e) return "No dates";
|
||||
if (s && !e) return `Started ${s}${suffix}`;
|
||||
if (!s) return `${e}${suffix}`;
|
||||
if (s === e) return `${e}${suffix}`;
|
||||
// Drop the repeated year: "2 Sep – 14 Sep 2026".
|
||||
const sameYear = start!.value.slice(0, 4) === end!.value.slice(0, 4) && start!.precision !== "year";
|
||||
const left = sameYear ? s.slice(0, -5) : s;
|
||||
return `${left} – ${e}${suffix}`;
|
||||
}
|
||||
|
||||
/** Accepts `YYYY`, `YYYY-MM` and `YYYY-MM-DD`. Anything else (including `03/04/2019`) is null. */
|
||||
export function parsePartialDate(input: string): PartialDate | null {
|
||||
const s = input.trim();
|
||||
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s);
|
||||
if (!m) return null;
|
||||
if (m[2] === undefined) return { value: s, precision: "year" };
|
||||
const month = Number(m[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
if (m[3] === undefined) return { value: s, precision: "month" };
|
||||
const day = Number(m[3]);
|
||||
const y = Number(m[1]);
|
||||
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||
const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!;
|
||||
return day >= 1 && day <= dim ? { value: s, precision: "day" } : null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
book: "Book",
|
||||
comic: "Comic",
|
||||
manga: "Manga",
|
||||
game: "Game",
|
||||
movie: "Movie",
|
||||
show: "Show",
|
||||
season: "Season",
|
||||
episode: "Episode",
|
||||
podcast: "Podcast",
|
||||
podcast_episode: "Podcast episode",
|
||||
audio_drama: "Audio drama",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
track: "Track",
|
||||
video: "Video",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export function mediaTypeLabel(type: string): string {
|
||||
return TYPE_LABELS[type] ?? type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/** The cover colour family for a media type (DESIGN.md "Media type colours"). */
|
||||
export function mediaColour(type: string): string {
|
||||
if (["book", "comic", "manga"].includes(type)) return "#6b3fa0";
|
||||
if (["podcast", "podcast_episode", "audio_drama"].includes(type)) return "#2f6b58";
|
||||
if (type === "game") return "#c1652f";
|
||||
if (["movie", "show", "season", "episode", "video"].includes(type)) return "#344b7a";
|
||||
if (["artist", "album", "track"].includes(type)) return "#a33b62";
|
||||
return "#4b4485";
|
||||
}
|
||||
|
||||
const CHILD_NOUN: Record<string, [string, string]> = {
|
||||
show: ["season", "seasons"],
|
||||
season: ["episode", "episodes"],
|
||||
podcast: ["episode", "episodes"],
|
||||
audio_drama: ["episode", "episodes"],
|
||||
manga: ["volume", "volumes"],
|
||||
comic: ["issue", "issues"],
|
||||
book: ["volume", "volumes"],
|
||||
album: ["track", "tracks"],
|
||||
artist: ["album", "albums"],
|
||||
};
|
||||
|
||||
/** Children roll up as "season 3 of 5", "5 seasons" (none finished) or "all 200 episodes". */
|
||||
export function rollupLabel(type: string, summary: { total: number; completed: number } | null): string | null {
|
||||
if (!summary || summary.total === 0) return null;
|
||||
const [one, many] = CHILD_NOUN[type] ?? ["part", "parts"];
|
||||
const { total, completed } = summary;
|
||||
if (completed === 0) return `${total} ${total === 1 ? one : many}`;
|
||||
if (completed >= total) return total === 1 ? `1 ${one}` : `all ${total} ${many}`;
|
||||
return `${one} ${completed} of ${total}`;
|
||||
}
|
||||
|
||||
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
|
||||
export const STATUSES: Status[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"];
|
||||
|
||||
/** Status in the words of the medium: "Reading", "Playing", "Watching", "Listening". */
|
||||
export function statusLabel(status: Status, type = "other"): string {
|
||||
if (status === "in_progress") {
|
||||
const verb = engagementNouns(type).verb;
|
||||
return verb;
|
||||
}
|
||||
return { planned: "Planned", paused: "Paused", completed: "Finished", dropped: "Dropped", none: "No status" }[status];
|
||||
}
|
||||
|
||||
/** What one time through a work is called: reads, plays, watches, listens. */
|
||||
export function engagementNouns(type: string): { box: string; one: string; verb: string; past: string; log: string } {
|
||||
if (["book", "comic", "manga"].includes(type)) return { box: "Reads", one: "read", verb: "Reading", past: "Read", log: "Log a read" };
|
||||
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", past: "Played", log: "Log a play" };
|
||||
if (["movie", "show", "season", "episode", "video"].includes(type))
|
||||
return { box: "Watches", one: "watch", verb: "Watching", past: "Watched", log: "Log a watch" };
|
||||
if (["podcast", "podcast_episode", "audio_drama", "artist", "album", "track"].includes(type))
|
||||
return { box: "Listens", one: "listen", verb: "Listening", past: "Listened", log: "Log a listen" };
|
||||
return { box: "Times through", one: "time", verb: "In progress", past: "Finished", log: "Log a time" };
|
||||
}
|
||||
|
||||
/** "12 Oct" for a real timestamp, in local time. */
|
||||
export function shortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
|
||||
}
|
||||
|
||||
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
|
||||
export function ordinal(n: number): string {
|
||||
const tens = n % 100;
|
||||
if (tens >= 11 && tens <= 13) return `${n}th`;
|
||||
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
|
||||
}
|
||||
|
||||
/** "4 min ago", "3 h ago", "2 days ago", else "14 Sep 2026". For real timestamps only. */
|
||||
export function timeAgo(iso: string, now: Date = new Date()): string {
|
||||
const t = new Date(iso);
|
||||
const s = Math.round((now.getTime() - t.getTime()) / 1000);
|
||||
if (Number.isNaN(s)) return "";
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
||||
if (s < 86400 * 7) return `${Math.floor(s / 86400)} ${s < 86400 * 2 ? "day" : "days"} ago`;
|
||||
return `${t.getDate()} ${MONTHS[t.getMonth()]} ${t.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** The connection id in a plugin actor `plugin:<pluginId>:<connectionId>`, else null. */
|
||||
export function actorConnectionId(actor: string): string | null {
|
||||
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Errors in plain words
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
startedAt: "Started",
|
||||
finishedAt: "Finished",
|
||||
acquiredAt: "When",
|
||||
acquiredFrom: "Bought at",
|
||||
priceCents: "Price",
|
||||
privateNotes: "Private notes",
|
||||
inviteCode: "Invite code",
|
||||
coverUrl: "Cover",
|
||||
workId: "Catalogue entry",
|
||||
};
|
||||
|
||||
/** "customFields.Website" → "Website"; "displayName" → "Display name". */
|
||||
function fieldLabel(field: string): string {
|
||||
if (field.startsWith("customFields.")) return field.slice("customFields.".length);
|
||||
const last = field.split(".").pop() ?? field;
|
||||
if (FIELD_LABELS[last]) return FIELD_LABELS[last]!;
|
||||
const words = last.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase();
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
const TOO_LARGE = "That file is too large. The limit is 25 MB.";
|
||||
|
||||
/**
|
||||
* An API error in the product's voice: what happened and how to fix it. Validation messages arrive as
|
||||
* `field: reason` (core) or `field: Zod message`, several joined by "; "; the first one is described.
|
||||
* Pass the user's rating scale so out-of-range ratings are described in the scale they see.
|
||||
*/
|
||||
export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale } = {}): string {
|
||||
if (!err || typeof err !== "object" || !("code" in err) || !("message" in err)) return "Something went wrong. Try again.";
|
||||
const { status, code, message } = err as { status?: number; code: string; message: string };
|
||||
if (status === 413) return TOO_LARGE;
|
||||
if (code !== "validation") return message;
|
||||
const first = message.split("; ")[0] ?? "";
|
||||
const i = first.indexOf(": ");
|
||||
const field = i > 0 ? first.slice(0, i) : "";
|
||||
const reason = i > 0 ? first.slice(i + 2) : first;
|
||||
const label = fieldLabel(field || "value");
|
||||
if (field === "file" && /larger|large/.test(reason)) return TOO_LARGE;
|
||||
if (field === "mime") return "That file type isn't supported. Use an image, a PDF or a text file.";
|
||||
if (field === "password") return "Passwords need at least 8 characters.";
|
||||
if (field === "username") return "Usernames are 2–32 characters: lowercase letters, digits and underscores.";
|
||||
if (field === "currency" || reason === "not_cents") return "Price needs a number like 14 or 14.99, and a three-letter currency like GBP.";
|
||||
if (reason === "not_url") return `${label} needs a full web address, starting with https://.`;
|
||||
if (reason === "not_date" || reason === "bad_precision") return `${label} needs a date like 2025, 2025-03 or 2025-03-14.`;
|
||||
if (reason === "out_of_range") {
|
||||
const scale = opts.ratingScale ?? "5half";
|
||||
const range = scale === "10" ? "0 and 10" : scale === "100" ? "0 and 100" : "0 and 5 stars";
|
||||
return label === "Rating" ? `Pick a rating between ${range}.` : `Pick a rating for ${label} between ${range}.`;
|
||||
}
|
||||
if (reason === "required" || /^Too small: .*>=1 (character|item)/.test(reason) || /received undefined$/.test(reason)) return `${label} can't be empty.`;
|
||||
if (reason === "too_long" || reason.startsWith("Too big")) return `${label} is too long. Shorten it and try again.`;
|
||||
if (reason.startsWith("Too small")) return `${label} is too short.`;
|
||||
if (reason === "not_number" || reason === "not_integer") return `${label} needs a number.`;
|
||||
if (reason === "unknown_option" || reason === "duplicate_option") return `${label} has an option that no longer exists. Pick one from the list.`;
|
||||
if (reason === "duplicate") return `${label} is already in use. Pick another.`;
|
||||
return `${label} wasn't accepted. Check it and try again.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Review draft sync
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A text field edited locally and saved on blur. `synced` is the server value the text last agreed
|
||||
* with; text that differs from it is unsaved and is never replaced. `pending` counts saves in flight
|
||||
* (one entry's saves run serially, so they settle in order). While any is pending, incoming server
|
||||
* values are only remembered in `incoming`. Values that are our own saves' echoes (optimistic updates
|
||||
* show a save's `stored`, rollbacks show its `was`) are listed in `echoes` and ignored; any other
|
||||
* value (an outside change, a Restore) is applied once the saves settle. So the outcome does not
|
||||
* depend on the order in which rollbacks, refetches and settle callbacks land.
|
||||
*/
|
||||
export interface Draft {
|
||||
text: string;
|
||||
synced: string;
|
||||
pending: number;
|
||||
incoming: string;
|
||||
echoes: string[];
|
||||
}
|
||||
|
||||
export function newDraft(value: string): Draft {
|
||||
return { text: value, synced: value, pending: 0, incoming: value, echoes: [] };
|
||||
}
|
||||
|
||||
/** A server value arrived. Untouched text follows it; edited text is kept. Deferred while saving. */
|
||||
export function draftIncoming(d: Draft, incoming: string): Draft {
|
||||
if (d.pending > 0 || incoming === d.synced) return { ...d, incoming };
|
||||
return { ...d, text: d.text === d.synced ? incoming : d.text, synced: incoming, incoming };
|
||||
}
|
||||
|
||||
/** A save of `stored` started (`was` is the synced value before it): it counts as synced unless it fails. */
|
||||
export function draftStartSave(d: Draft, save: { was: string; stored: string }): Draft {
|
||||
return { ...d, synced: save.stored, pending: d.pending + 1, echoes: [...d.echoes, save.was, save.stored] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The oldest pending save settled. Success: `stored` is on the server. Failure: back to `was`, so the
|
||||
* local text still counts as unsaved. When the last pending save settles, a remembered incoming value
|
||||
* that is not one of our own echoes is applied.
|
||||
*/
|
||||
export function draftSettled(d: Draft, save: { was: string; stored: string }, ok: boolean): Draft {
|
||||
const pending = Math.max(0, d.pending - 1);
|
||||
const next: Draft = { ...d, synced: ok ? save.stored : save.was, pending };
|
||||
if (pending > 0) return next;
|
||||
const settled: Draft = { ...next, echoes: [] };
|
||||
return d.echoes.includes(d.incoming) ? settled : draftIncoming(settled, d.incoming);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
|
||||
import { applyEntryPatch } from "./edits.ts";
|
||||
|
||||
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
|
||||
export function usePendingChanges() {
|
||||
return useQuery({ queryKey: ["changes", "pending"], queryFn: api.changes, staleTime: 30_000 });
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false, staleTime: Infinity });
|
||||
}
|
||||
|
||||
export function useSources() {
|
||||
return useQuery({ queryKey: ["sources"], queryFn: api.sources, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
/** Pending events that touch an entry or one of its reads/plays. */
|
||||
export function changesFor(items: ChangeEvent[] | undefined, entryId: string, engagementIds: Set<string>): ChangeEvent[] {
|
||||
return (items ?? []).filter(
|
||||
(c) => c.targetId !== null && ((c.targetEntity === "entry" && c.targetId === entryId) || (c.targetEntity === "engagement" && engagementIds.has(c.targetId))),
|
||||
);
|
||||
}
|
||||
|
||||
export function useDebounced<T>(value: T, ms = 220): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared save path for every detail edit. The cached entry is updated optimistically (rolled back on
|
||||
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
|
||||
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
|
||||
* builds from the latest cache, so quick successive edits compose.
|
||||
*
|
||||
* `onSettled` fires once for EVERY mutation of this hook (it is a hook-level option). Per-call
|
||||
* `mutate(patch, { onSuccess })` callbacks only fire for the observer's latest call, so never use
|
||||
* them for bookkeeping.
|
||||
*/
|
||||
export function useEntryPatch(id: string, opts: { onSettled?: (patch: EntryPatch, error: Error | null) => void } = {}) {
|
||||
const qc = useQueryClient();
|
||||
const onSettledRef = useRef(opts.onSettled);
|
||||
onSettledRef.current = opts.onSettled;
|
||||
const key = ["entry", id];
|
||||
const mutationKey = ["entry-patch", id];
|
||||
const m = useMutation({
|
||||
mutationKey,
|
||||
scope: { id: `entry-patch:${id}` },
|
||||
mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
|
||||
onMutate: async (patch) => {
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<EntryView>(key);
|
||||
if (prev) qc.setQueryData<EntryView>(key, applyEntryPatch<EntryView>(prev, patch));
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _patch, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
onSuccess: (entry) => {
|
||||
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
|
||||
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
|
||||
},
|
||||
onSettled: (_entry, error, patch) => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
||||
onSettledRef.current?.(patch, error);
|
||||
},
|
||||
});
|
||||
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
|
||||
const cur = qc.getQueryData<EntryView>(key);
|
||||
const patch = cur ? build(cur) : null;
|
||||
if (patch) m.mutate(patch);
|
||||
};
|
||||
return { ...m, edit };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Redirect, Route, Switch } from "wouter";
|
||||
import { ApiError } from "./api.ts";
|
||||
import { friendlyError } from "./format.ts";
|
||||
import { Shell } from "./components/Shell.tsx";
|
||||
import { useMe } from "./hooks.ts";
|
||||
import { Library } from "./routes/library.tsx";
|
||||
import { Login } from "./routes/login.tsx";
|
||||
import { NotYet } from "./routes/not-yet.tsx";
|
||||
import { Register } from "./routes/register.tsx";
|
||||
|
||||
const queryClient: QueryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
// A 401 anywhere means the session ended: re-check who we are, which sends the user to sign in.
|
||||
onError: (e, query) => {
|
||||
if (e instanceof ApiError && e.status === 401 && query.queryKey[0] !== "me") void queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 15_000,
|
||||
retry: (n, e) => !(e instanceof ApiError && e.status > 0 && e.status < 500) && n < 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const me = useMe();
|
||||
if (me.isPending) return <div className="boot"><span className="logo">STTracker</span></div>;
|
||||
if (me.error instanceof ApiError && me.error.status === 401) return <Redirect to="/login" replace />;
|
||||
if (me.error) {
|
||||
return (
|
||||
<div className="auth">
|
||||
<div className="auth-card" role="alert">
|
||||
<span className="logo">STTracker</span>
|
||||
<h1>Can't load your library</h1>
|
||||
<p className="lede">{friendlyError(me.error)}</p>
|
||||
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/register" component={Register} />
|
||||
<Route>
|
||||
<RequireAuth>
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/">{() => <Library selectedId={null} />}</Route>
|
||||
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
|
||||
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
|
||||
</Switch>
|
||||
</Shell>
|
||||
</RequireAuth>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { api, type EntryView } from "../api.ts";
|
||||
import { Cover } from "../components/Cover.tsx";
|
||||
import { EntryDetail } from "../components/EntryDetail.tsx";
|
||||
import { Icon } from "../components/Icon.tsx";
|
||||
import { describeRating, formatRating, friendlyError, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
|
||||
import { useDebounced, usePendingChanges } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
const TYPE_FILTERS = ["book", "manga", "comic", "game", "movie", "show", "podcast", "audio_drama", "album", "artist", "video", "other"];
|
||||
const STATUS_FILTERS: Status[] = ["in_progress", "planned", "completed", "paused", "dropped"];
|
||||
|
||||
/** The library: list pane (search, quick add, filters, rows) and the detail pane. */
|
||||
export function Library({ selectedId }: { selectedId: string | null }) {
|
||||
return (
|
||||
<div className={`library${selectedId ? " has-selection" : ""}`}>
|
||||
<ListPane selectedId={selectedId} />
|
||||
<section className="detail-pane" aria-label="Entry">
|
||||
{selectedId ? (
|
||||
<EntryDetail key={selectedId} id={selectedId} />
|
||||
) : (
|
||||
<div className="detail-empty">
|
||||
<p>
|
||||
<strong>Nothing selected</strong>
|
||||
Pick something from your library, or quick add a title and sort out the details later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filters survive navigating between entries (the list pane remounts on route change).
|
||||
type Filters = { q: string; mediaType?: string; status?: Status; draft: boolean };
|
||||
let savedFilters: Filters = { q: "", draft: false };
|
||||
|
||||
/** Forget per-session library state (on sign-out). */
|
||||
export function resetLibraryState(): void {
|
||||
savedFilters = { q: "", draft: false };
|
||||
}
|
||||
|
||||
function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
const [, navigate] = useLocation();
|
||||
const qc = useQueryClient();
|
||||
const scale = useRatingScale();
|
||||
const [filters, setFiltersState] = useState(savedFilters);
|
||||
const setFilters = (f: typeof filters) => {
|
||||
savedFilters = f;
|
||||
setFiltersState(f);
|
||||
};
|
||||
const q = useDebounced(filters.q.trim());
|
||||
const query = { q: q || undefined, mediaType: filters.mediaType, status: filters.status, draft: filters.draft || undefined };
|
||||
const entries = useInfiniteQuery({
|
||||
queryKey: ["entries", query],
|
||||
queryFn: ({ pageParam }) => api.entries({ ...query, cursor: pageParam, limit: 50 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
const changes = usePendingChanges();
|
||||
const flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
|
||||
|
||||
const [quickError, setQuickError] = useState<string | null>(null);
|
||||
const quickInput = useRef<HTMLInputElement>(null);
|
||||
const quick = useMutation({
|
||||
mutationFn: api.quickAdd,
|
||||
onSuccess: (entry, _t) => {
|
||||
qc.setQueryData(["entry", entry.id], entry);
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
navigate(`/entries/${entry.id}`);
|
||||
if (quickInput.current) quickInput.current.value = "";
|
||||
quickInput.current?.focus();
|
||||
},
|
||||
onError: (e) => setQuickError(friendlyError(e)),
|
||||
});
|
||||
function onQuick(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const title = (quickInput.current?.value ?? "").trim();
|
||||
if (!title || quick.isPending) return;
|
||||
setQuickError(null);
|
||||
quick.mutate(title);
|
||||
}
|
||||
|
||||
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const filtering = Boolean(q || filters.mediaType || filters.status || filters.draft);
|
||||
|
||||
return (
|
||||
<section className="list-pane" aria-label="Library">
|
||||
<div className="list-tools">
|
||||
<form role="search" className="search" onSubmit={(e) => e.preventDefault()}>
|
||||
<Icon name="search" />
|
||||
<label className="sr-only" htmlFor="lib-search">Search your library</label>
|
||||
<input
|
||||
id="lib-search"
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Search your library…"
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||
/>
|
||||
</form>
|
||||
<form className="quick" onSubmit={onQuick}>
|
||||
<span className="plus"><Icon name="plus" /></span>
|
||||
<label className="sr-only" htmlFor="quick-add">Quick add</label>
|
||||
<input
|
||||
id="quick-add"
|
||||
ref={quickInput}
|
||||
name="title"
|
||||
className="input"
|
||||
placeholder="Quick add: type a title, sort it out later"
|
||||
autoComplete="off"
|
||||
maxLength={1000}
|
||||
readOnly={quick.isPending}
|
||||
aria-busy={quick.isPending}
|
||||
aria-describedby={quickError ? "quick-error" : undefined}
|
||||
/>
|
||||
</form>
|
||||
{quickError && <p className="error-text" id="quick-error" role="alert">{quickError}</p>}
|
||||
<div className="filters">
|
||||
<div className="chips" role="group" aria-label="Filter by status">
|
||||
<button
|
||||
type="button"
|
||||
className="chip draft-chip"
|
||||
aria-pressed={filters.draft}
|
||||
onClick={() => setFilters({ ...filters, draft: !filters.draft })}
|
||||
>
|
||||
Draft
|
||||
</button>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="chip"
|
||||
aria-pressed={filters.status === s}
|
||||
onClick={() => setFilters({ ...filters, status: filters.status === s ? undefined : s })}
|
||||
>
|
||||
{statusLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chips" role="group" aria-label="Filter by media type">
|
||||
{TYPE_FILTERS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className="chip"
|
||||
aria-pressed={filters.mediaType === t}
|
||||
onClick={() => setFilters({ ...filters, mediaType: filters.mediaType === t ? undefined : t })}
|
||||
>
|
||||
{mediaTypeLabel(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.isPending ? (
|
||||
<ul className="list" aria-busy="true" aria-label="Loading your library">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<li key={i} className="row skeleton" aria-hidden="true">
|
||||
<div className="cover" />
|
||||
<div><div className="bar" /><div className="bar" /></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : entries.error ? (
|
||||
<div className="list-note" role="alert">
|
||||
<p>{friendlyError(entries.error)}</p>
|
||||
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="list-note">
|
||||
{filtering ? (
|
||||
<>
|
||||
<p>Nothing matches these filters.</p>
|
||||
<button className="btn small" onClick={() => setFilters({ q: "", draft: false })}>Clear filters</button>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<strong>Your library is empty.</strong> Type a title into Quick add and press Enter. A title alone is
|
||||
enough; you can link it to a catalogue entry later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{items.map((e) => (
|
||||
<li key={e.id}>
|
||||
<Row entry={e} selected={e.id === selectedId} flagged={flagged.has(e.id)} scale={scale} />
|
||||
</li>
|
||||
))}
|
||||
{entries.hasNextPage && (
|
||||
<li className="more">
|
||||
<button className="btn small" disabled={entries.isFetchingNextPage} onClick={() => void entries.fetchNextPage()}>
|
||||
{entries.isFetchingNextPage ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** `media type · meta`: children roll-up, else status, plus the year. */
|
||||
export function rowMeta(e: EntryView): string {
|
||||
const type = e.mediaTypeOverride ?? e.work.mediaType;
|
||||
const parts = [mediaTypeLabel(type)];
|
||||
const rollup = rollupLabel(type, e.childSummary);
|
||||
if (rollup) parts.push(rollup);
|
||||
else if (e.status !== "none") parts.push(statusLabel(e.status, type).toLowerCase());
|
||||
if (e.work.year) parts.push(String(e.work.year));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType<typeof useRatingScale> }) {
|
||||
return (
|
||||
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
|
||||
<Cover url={e.work.coverUrl} mediaType={e.mediaTypeOverride ?? e.work.mediaType} draft={e.draft} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="title">{e.work.title}</span>
|
||||
<div className="meta">{rowMeta(e)}</div>
|
||||
</div>
|
||||
<span className="end">
|
||||
{flagged && <span className="flag" role="img" aria-label="A source changed this" title="A source changed this" />}
|
||||
{e.draft ? (
|
||||
<span className="draft">Draft</span>
|
||||
) : e.rating !== null ? (
|
||||
<span className="stars" role="img" aria-label={describeRating(e.rating, scale)}>{formatRating(e.rating, scale)}</span>
|
||||
) : e.status === "in_progress" ? (
|
||||
<span className="flag active" role="img" aria-label="In progress" title="In progress" />
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, Redirect, useLocation } from "wouter";
|
||||
import { api } from "../api.ts";
|
||||
import { friendlyError } from "../format.ts";
|
||||
|
||||
export function Login() {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
|
||||
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await api.login(String(f.get("username")), String(f.get("password")));
|
||||
qc.setQueryData(["me"], user);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh instance has nobody to sign in as: go straight to creating the admin account.
|
||||
if (status.data?.hasUsers === false) return <Redirect to="/register" replace />;
|
||||
|
||||
return (
|
||||
<main className="auth">
|
||||
<div className="auth-card">
|
||||
<span className="logo">STTracker</span>
|
||||
<h1>Sign in</h1>
|
||||
<p className="lede">Everything you read, watch, play and listen to, in one place that's yours.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<label className="field">
|
||||
<span>Username</span>
|
||||
<input className="input" name="username" autoComplete="username" autoCapitalize="none" spellCheck={false} required autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input className="input" name="password" type="password" autoComplete="current-password" required />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||
</form>
|
||||
<p className="alt">
|
||||
Got an invite? <Link className="text-link" href="/register">Create your account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const NAMES: Record<string, string> = {
|
||||
log: "Log",
|
||||
lists: "Lists",
|
||||
owned: "Owned",
|
||||
changes: "Changes",
|
||||
sources: "Sources",
|
||||
settings: "Settings",
|
||||
about: "About",
|
||||
};
|
||||
|
||||
/** Sections that arrive in the next update. Unknown paths say so plainly. */
|
||||
export function NotYet({ page }: { page: string }) {
|
||||
const name = NAMES[page];
|
||||
return (
|
||||
<section className="page">
|
||||
<h1>{name ?? "Nothing here"}</h1>
|
||||
<p>
|
||||
{name
|
||||
? `${name} isn't built yet. Your data is safe; this screen arrives in the next update.`
|
||||
: "This address doesn't match any page. Head back to your library."}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useLocation, useSearch } from "wouter";
|
||||
import { ApiError, api } from "../api.ts";
|
||||
import { friendlyError } from "../format.ts";
|
||||
|
||||
/**
|
||||
* First-run screen. The first account becomes the admin; later accounts need an invite code.
|
||||
* `GET auth/status` says which; `?invite=` prefills the code from an invite link.
|
||||
*/
|
||||
export function Register() {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const invite = new URLSearchParams(useSearch()).get("invite") ?? "";
|
||||
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
|
||||
const [inviteRequired, setInviteRequired] = useState(false);
|
||||
const needInvite = invite !== "" || inviteRequired || status.data?.hasUsers === true;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
const username = String(f.get("username")).trim().toLowerCase();
|
||||
const password = String(f.get("password"));
|
||||
const displayName = String(f.get("displayName") ?? "").trim();
|
||||
const inviteCode = String(f.get("inviteCode") ?? "").trim();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.register({ username, password, displayName: displayName || undefined, inviteCode: inviteCode || undefined });
|
||||
void qc.invalidateQueries({ queryKey: ["auth-status"] });
|
||||
qc.setQueryData(["me"], await api.login(username, password));
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === "invite_required") {
|
||||
setInviteRequired(true);
|
||||
setError(inviteCode ? "That invite code didn't work. Check it, or ask the admin for a new one." : "This STTracker already has an admin. Ask them for an invite code and enter it below.");
|
||||
} else setError(friendlyError(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isPending && !invite) return <div className="boot"><span className="logo">STTracker</span></div>;
|
||||
|
||||
return (
|
||||
<main className="auth">
|
||||
<div className="auth-card">
|
||||
<span className="logo">STTracker</span>
|
||||
<h1>{needInvite ? "Create your account" : "Create the admin account"}</h1>
|
||||
<p className="lede">
|
||||
{needInvite
|
||||
? "You've been invited to this STTracker. Pick a username and password."
|
||||
: "This is the first account on this STTracker. It can invite friends later."}
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<label className="field">
|
||||
<span>Your name (optional)</span>
|
||||
<input className="input" name="displayName" autoComplete="name" maxLength={100} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
className="input"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
aria-describedby="username-hint"
|
||||
/>
|
||||
<small className="hint" id="username-hint">2–32 lowercase letters, digits and underscores.</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input className="input" name="password" type="password" autoComplete="new-password" required minLength={8} aria-describedby="password-hint" />
|
||||
<small className="hint" id="password-hint">At least 8 characters.</small>
|
||||
</label>
|
||||
{needInvite && (
|
||||
<label className="field">
|
||||
<span>Invite code</span>
|
||||
<input className="input" name="inviteCode" defaultValue={invite} required autoComplete="off" spellCheck={false} />
|
||||
</label>
|
||||
)}
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Creating…" : "Create account"}</button>
|
||||
</form>
|
||||
<p className="alt">
|
||||
Already have an account? <Link className="text-link" href="/login">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api.ts";
|
||||
import type { RatingScale } from "./format.ts";
|
||||
|
||||
/** The signed-in user's preferences (GET me/preferences). Settings updates them with `api.updatePreferences`. */
|
||||
export function usePreferences() {
|
||||
return useQuery({ queryKey: ["preferences"], queryFn: api.preferences, staleTime: 5 * 60_000 });
|
||||
}
|
||||
|
||||
/** The rating scale preference; five stars with halves until preferences load. */
|
||||
export function useRatingScale(): RatingScale {
|
||||
return usePreferences().data?.ratingScale ?? "5half";
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/* STTracker app theme. Tokens are DESIGN.md's, verbatim; derived values sit below them. */
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("./fonts/inter-latin-wght-normal.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Gelasio (OFL), official variable fonts from github.com/google/fonts ofl/gelasio, unmodified. */
|
||||
@font-face {
|
||||
font-family: "Gelasio";
|
||||
src: url("./fonts/gelasio-wght.ttf") format("truetype");
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Gelasio";
|
||||
src: url("./fonts/gelasio-italic-wght.ttf") format("truetype");
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* DESIGN.md */
|
||||
--surface-0: #17152b;
|
||||
--surface-1: #1e1b36;
|
||||
--surface-2: #262247;
|
||||
--surface-3: #2d2852;
|
||||
--text: #ece9ff;
|
||||
--text-muted: #a9a3d6;
|
||||
--text-faint: #8d87c2;
|
||||
--accent: #6d5dfc;
|
||||
--rating: #fbbf24;
|
||||
--warn: #f97316;
|
||||
--ok: #34d399;
|
||||
|
||||
--media-books: #6b3fa0;
|
||||
--media-audio: #2f6b58;
|
||||
--media-games: #c1652f;
|
||||
--media-film: #344b7a;
|
||||
--media-music: #a33b62;
|
||||
|
||||
/* Derived (from the approved mockup) */
|
||||
--nav-text: #b8b3e0;
|
||||
--pill-text: #c9c4f0;
|
||||
--dash: #4b4485;
|
||||
--focus: #9d92ff;
|
||||
--warn-bg: #3a2a1a;
|
||||
--warn-line: #f9731666;
|
||||
--warn-text: #fde2c8;
|
||||
--on-warn: #1c0f04; /* white on --warn is 2.8:1; this is 6.4:1 */
|
||||
--review-text: #e3e0fb;
|
||||
--draft-stripes: repeating-linear-gradient(45deg, #3a356b 0 6px, #2d2852 6px 12px);
|
||||
|
||||
--r-control: 8px;
|
||||
--r-box: 10px;
|
||||
--r-pill: 999px;
|
||||
--r-cover: 4px;
|
||||
|
||||
--font-ui: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-review: "Gelasio", Georgia, "Times New Roman", serif;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background: var(--surface-1);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
caret-color: var(--text);
|
||||
}
|
||||
::selection { background: #6d5dfc80; color: #fff; }
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; background: none; border: 0; }
|
||||
button:disabled { cursor: default; opacity: 0.55; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a.text-link, .text-link { color: var(--focus); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.tabular { font-variant-numeric: tabular-nums; }
|
||||
svg.icon { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-control);
|
||||
padding: 8px 11px;
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: var(--text-muted); }
|
||||
.input:hover, .select:hover, .textarea:hover { border-color: var(--dash); }
|
||||
.input:focus-visible, .select:focus-visible, .textarea:focus-visible { outline: none; border-color: var(--focus); }
|
||||
.select { appearance: none; padding-right: 30px; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: calc(100% - 15px) 52%, calc(100% - 10px) 52%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-radius: var(--r-control); padding: 7px 13px; font-size: 0.82rem; font-weight: 500;
|
||||
background: var(--surface-3); color: var(--text);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #3a346a; }
|
||||
.btn.primary { background: var(--accent); color: #fff; }
|
||||
.btn.primary:hover:not(:disabled) { background: #7c6dff; }
|
||||
.btn.ghost { background: transparent; color: var(--text-muted); padding: 5px 8px; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--surface-3); color: var(--text); }
|
||||
.btn.small { padding: 4px 9px; font-size: 0.76rem; }
|
||||
.field { display: grid; gap: 5px; }
|
||||
.field > span { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
|
||||
.hint { font-size: 0.76rem; color: var(--text-faint); }
|
||||
.error-text { font-size: 0.8rem; color: #ffb4a8; }
|
||||
.box .hint, .box .error-text { margin-top: 6px; }
|
||||
|
||||
/* ---------- Auth screens ---------- */
|
||||
.auth {
|
||||
min-height: 100%;
|
||||
display: grid; place-items: center;
|
||||
padding: 40px 20px;
|
||||
background:
|
||||
radial-gradient(60rem 40rem at 85% -10%, #6d5dfc22, transparent 60%),
|
||||
radial-gradient(40rem 30rem at -10% 110%, #fbbf2412, transparent 60%),
|
||||
var(--surface-0);
|
||||
}
|
||||
.auth-card { width: min(400px, 100%); }
|
||||
.auth .logo { font-size: 1.15rem; margin: 0 0 28px; }
|
||||
.auth h1 { font-size: 1.6rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.015em; text-wrap: balance; }
|
||||
.auth .lede { color: var(--text-muted); margin: 8px 0 26px; max-width: 42ch; }
|
||||
.auth form { display: grid; gap: 14px; }
|
||||
.auth form .btn.primary { justify-content: center; padding: 10px 14px; font-size: 0.9rem; margin-top: 6px; }
|
||||
.auth .alt { margin-top: 22px; color: var(--text-muted); font-size: 0.85rem; }
|
||||
.form-error { background: #3a1d24; border: 1px solid #f8717155; color: #ffd4d4; border-radius: var(--r-control); padding: 9px 12px; font-size: 0.84rem; }
|
||||
|
||||
/* ---------- Shell ---------- */
|
||||
.logo { font-weight: 700; font-size: 1.05rem; color: var(--rating); letter-spacing: -0.01em; display: inline-block; }
|
||||
.shell { display: grid; grid-template-columns: 190px minmax(0, 1fr); height: 100%; }
|
||||
.side {
|
||||
background: var(--surface-0); padding: 20px 14px 14px;
|
||||
display: flex; flex-direction: column; overflow-y: auto;
|
||||
}
|
||||
.side .logo { margin: 0 8px 20px; }
|
||||
.side nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav {
|
||||
padding: 7px 10px; border-radius: var(--r-control); color: var(--nav-text);
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.nav:hover { background: #221f40; color: var(--text); }
|
||||
.nav[aria-current="page"] { background: var(--surface-3); color: #fff; }
|
||||
.nav .count { background: var(--warn); color: var(--on-warn); font-weight: 650; border-radius: var(--r-pill); font-size: 0.72rem; padding: 1px 7px; line-height: 1.4; }
|
||||
.nav .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex: none; }
|
||||
.nav .dot.paused { background: var(--dash); }
|
||||
.nav .name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.grp { color: var(--text-faint); font-size: 0.78rem; margin: 18px 10px 6px; font-weight: 500; }
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 0; display: grid; gap: 2px; font-size: 0.8rem; }
|
||||
.side-foot .who { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side-foot button { justify-self: start; color: var(--text-faint); padding: 2px 0; }
|
||||
.side-foot button:hover { color: var(--text); }
|
||||
.main { min-width: 0; height: 100%; overflow: hidden; }
|
||||
|
||||
/* ---------- Library ---------- */
|
||||
.library { display: grid; grid-template-columns: 360px minmax(0, 1fr); height: 100%; }
|
||||
.list-pane { border-right: 1px solid var(--surface-3); display: flex; flex-direction: column; min-height: 0; }
|
||||
.list-tools { padding: 18px 16px 10px; display: grid; gap: 12px; }
|
||||
.search { position: relative; }
|
||||
.search .icon { position: absolute; left: 11px; top: 50%; translate: 0 -50%; color: var(--text-muted); pointer-events: none; }
|
||||
.search .input { padding-left: 33px; }
|
||||
.quick { position: relative; }
|
||||
.quick .input {
|
||||
background: transparent; border: 1px dashed var(--dash); color: var(--pill-text); padding-left: 32px;
|
||||
}
|
||||
.quick .input::placeholder { color: var(--pill-text); }
|
||||
.quick .input:hover { border-color: #6a62b0; }
|
||||
.quick .input:focus-visible { border-style: solid; border-color: var(--rating); background: #221f40; }
|
||||
.quick .plus { position: absolute; left: 11px; top: 50%; translate: 0 -50%; color: var(--rating); pointer-events: none; }
|
||||
.quick .plus .icon { stroke-width: 2.25; }
|
||||
.chips { display: flex; gap: 5px; overflow-x: auto; scrollbar-width: none; padding: 2px 24px 2px 2px; margin: -2px -16px -2px -2px; mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
flex: none; border-radius: var(--r-pill); padding: 3px 10px; font-size: 0.76rem;
|
||||
background: transparent; color: var(--text-muted); border: 1px solid var(--surface-3);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.chip:hover { color: var(--text); border-color: var(--dash); }
|
||||
.chip[aria-pressed="true"] { background: var(--surface-3); color: #fff; border-color: var(--surface-3); }
|
||||
.chip.draft-chip[aria-pressed="true"] { color: var(--rating); border-color: #fbbf2466; background: #fbbf2414; }
|
||||
.filters { display: grid; gap: 6px; }
|
||||
.list { list-style: none; overflow-y: auto; flex: 1; padding-bottom: 12px; }
|
||||
.row {
|
||||
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 12px; align-items: center;
|
||||
padding: 9px 16px; transition: background 0.12s;
|
||||
}
|
||||
.row:hover { background: #24204a; }
|
||||
.row[aria-current="page"] { background: var(--surface-3); }
|
||||
.row:focus-visible { outline-offset: -2px; border-radius: 0; }
|
||||
.row .title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.row .meta { color: var(--text-muted); font-size: 0.76rem; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row .end { display: flex; align-items: center; gap: 8px; }
|
||||
.row .stars { font-size: 0.75rem; }
|
||||
.flag { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); flex: none; }
|
||||
.flag.active { background: var(--accent); }
|
||||
.draft { font-size: 0.7rem; color: var(--rating); border: 1px solid #fbbf2466; border-radius: 4px; padding: 1px 5px; line-height: 1.35; }
|
||||
.stars { color: var(--rating); letter-spacing: 2px; white-space: nowrap; }
|
||||
.list-note { padding: 18px 16px; color: var(--text-muted); font-size: 0.85rem; display: grid; gap: 10px; justify-items: start; }
|
||||
.list-note strong { color: var(--text); font-weight: 600; }
|
||||
.more { margin: 10px 16px; }
|
||||
.skeleton .cover, .skeleton .bar { background: var(--surface-2); }
|
||||
.skeleton .bar { height: 10px; border-radius: 4px; width: 70%; }
|
||||
.skeleton .bar + .bar { width: 40%; margin-top: 7px; height: 8px; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.skeleton { animation: pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.55; } }
|
||||
}
|
||||
|
||||
/* ---------- Covers ---------- */
|
||||
.cover { width: 40px; height: 56px; border-radius: 3px; overflow: hidden; flex: none; background: var(--surface-2); }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cover.is-draft { background: var(--draft-stripes); }
|
||||
.cover.large { width: 110px; height: 160px; border-radius: 5px; box-shadow: 0 6px 20px #0006; }
|
||||
.cover.small { width: 28px; height: 40px; border-radius: 3px; }
|
||||
|
||||
/* ---------- Detail ---------- */
|
||||
.detail-pane { overflow-y: auto; min-width: 0; position: relative; }
|
||||
.detail { padding: 22px 26px 40px; max-width: 980px; }
|
||||
.detail-empty { height: 100%; display: grid; place-items: center; padding: 40px; text-align: center; color: var(--text-muted); }
|
||||
.detail-empty p { max-width: 34ch; }
|
||||
.detail-empty strong { display: block; color: var(--text); font-weight: 600; font-size: 1rem; margin-bottom: 6px; }
|
||||
.back { display: none; }
|
||||
.det-head { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 20px; }
|
||||
.det-head h1 { font-size: 1.45rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.012em; text-wrap: balance; }
|
||||
.det-head .m { color: var(--text-muted); margin: 4px 0 12px; }
|
||||
.det-head .draft { margin-left: 8px; vertical-align: 3px; }
|
||||
.det-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.det-controls .select { width: auto; padding-top: 5px; padding-bottom: 5px; font-size: 0.82rem; }
|
||||
.det-tools { margin-left: auto; display: flex; gap: 4px; }
|
||||
.pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
|
||||
.pill { background: var(--surface-3); padding: 3px 4px 3px 10px; border-radius: var(--r-pill); font-size: 0.76rem; color: var(--pill-text); display: inline-flex; align-items: center; gap: 2px; }
|
||||
.pill button { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: var(--text-muted); }
|
||||
.pill button:hover { background: #ffffff14; color: var(--text); }
|
||||
.pill button .icon { width: 11px; height: 11px; stroke-width: 2.25; }
|
||||
.tag-input { background: transparent; border: 1px dashed var(--dash); border-radius: var(--r-pill); padding: 2px 10px; font-size: 0.76rem; width: 96px; color: var(--pill-text); }
|
||||
.tag-input::placeholder { color: var(--text-muted); }
|
||||
.tag-input:focus-visible { outline: none; border-style: solid; border-color: var(--focus); width: 150px; }
|
||||
|
||||
/* Rating control */
|
||||
.rating { display: inline-flex; align-items: center; gap: 2px; border-radius: 6px; padding: 2px; cursor: pointer; }
|
||||
.rating svg { width: 20px; height: 20px; display: block; }
|
||||
.rating .empty { fill: none; stroke: #6a62b0; stroke-width: 1.4; }
|
||||
.rating .full { fill: var(--rating); stroke: var(--rating); stroke-width: 1.4; }
|
||||
.rating-clear { margin-left: 6px; font-size: 0.74rem; color: var(--text-faint); padding: 2px 4px; border-radius: 4px; }
|
||||
.rating-clear:hover { color: var(--text); }
|
||||
.rating-number { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 0.82rem; }
|
||||
.rating-number .input { width: 72px; padding: 5px 8px; }
|
||||
|
||||
/* Change alert */
|
||||
.alert {
|
||||
margin: 18px 0 14px; background: var(--warn-bg); border: 1px solid var(--warn-line);
|
||||
border-radius: var(--r-box); padding: 11px 14px; display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
.alert p { flex: 1; color: var(--warn-text); font-size: 0.85rem; line-height: 1.4; }
|
||||
.alert p b { font-weight: 650; }
|
||||
.alert .btn { background: var(--warn); color: var(--on-warn); font-weight: 600; border-radius: 6px; padding: 6px 12px; font-size: 0.8rem; white-space: nowrap; }
|
||||
.alert .btn:hover { background: #fb8a3c; }
|
||||
|
||||
/* Boxes */
|
||||
.det-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 18px; }
|
||||
.alert + .det-grid, .alert + .link-box { margin-top: 0; }
|
||||
.box { background: var(--surface-2); border-radius: var(--r-box); padding: 12px 14px; min-width: 0; }
|
||||
.box.wide { grid-column: 1 / -1; }
|
||||
.box-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; min-height: 22px; }
|
||||
.box h2 { font-size: 0.78rem; color: var(--text-faint); font-weight: 600; }
|
||||
.box .r { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 4px 0; font-size: 0.84rem; }
|
||||
.box .r > :first-child { color: var(--text-muted); }
|
||||
.box .r > :last-child { text-align: right; }
|
||||
.box .r .x { opacity: 0; transition: opacity 0.15s; }
|
||||
.box .r:hover .x, .box .r .x:focus-visible { opacity: 1; }
|
||||
@media (hover: none) { .box .r .x { opacity: 1; } }
|
||||
.box .empty-line { color: var(--text-muted); font-size: 0.84rem; }
|
||||
.box form { display: grid; gap: 10px; margin-top: 8px; }
|
||||
.box form .two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.box form .actions { display: flex; gap: 6px; }
|
||||
.box .input, .box .select { padding: 6px 9px; font-size: 0.84rem; }
|
||||
.box .search .input { padding-left: 33px; }
|
||||
.copy + .copy { border-top: 1px solid var(--surface-3); margin-top: 6px; padding-top: 6px; }
|
||||
.thumbs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.thumb { width: 58px; height: 44px; border-radius: 4px; background-color: var(--surface-3); background-size: cover; background-position: center; display: grid; place-items: center; font-size: 0.66rem; color: var(--text-muted); overflow: hidden; text-align: center; padding: 2px; }
|
||||
.thumb.upload { border: 1px dashed var(--dash); background: transparent; color: var(--text-muted); cursor: pointer; }
|
||||
.thumb.upload:hover { color: var(--text); border-color: #6a62b0; }
|
||||
.thumb.upload:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.src { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 0.84rem; }
|
||||
.src i { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); flex: none; }
|
||||
.src i.paused { background: var(--dash); }
|
||||
.children { list-style: none; }
|
||||
.children a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 5px 6px; margin: 0 -6px; border-radius: 6px; font-size: 0.84rem; }
|
||||
.children a:hover { background: var(--surface-3); }
|
||||
.children .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.children .s { color: var(--text-muted); font-size: 0.76rem; margin-left: 8px; }
|
||||
.children .stars { font-size: 0.72rem; }
|
||||
.review textarea {
|
||||
width: 100%; min-height: 7.5em; resize: vertical; background: transparent; border: 1px solid transparent;
|
||||
border-radius: var(--r-control); padding: 6px 8px; margin: -6px -8px -4px; width: calc(100% + 16px);
|
||||
font-family: var(--font-review); font-size: 0.95rem; line-height: 1.55; color: var(--review-text);
|
||||
}
|
||||
.review textarea::placeholder { color: var(--text-faint); font-style: italic; }
|
||||
.review textarea:hover { border-color: var(--surface-3); }
|
||||
.review textarea:focus-visible { outline: none; border-color: var(--focus); background: #211d40; }
|
||||
.saved { font-size: 0.74rem; color: var(--text-faint); }
|
||||
.custom { display: grid; gap: 10px; }
|
||||
.custom .checks { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 0.84rem; }
|
||||
.custom label.inline { display: inline-flex; gap: 6px; align-items: center; font-size: 0.84rem; }
|
||||
input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||
|
||||
/* Link to catalogue */
|
||||
.link-box { margin: 18px 0 14px; border: 1px solid #fbbf2440; background: #fbbf240a; }
|
||||
.link-box p { color: var(--text-muted); font-size: 0.84rem; max-width: 70ch; }
|
||||
.link-box .results { list-style: none; margin-top: 10px; display: grid; gap: 4px; }
|
||||
.link-box .results li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 5px 6px; border-radius: 6px; font-size: 0.84rem; }
|
||||
.link-box .results li:hover { background: var(--surface-3); }
|
||||
.link-box .results .m { color: var(--text-muted); font-size: 0.76rem; }
|
||||
.link-box .results .acts { display: flex; gap: 4px; }
|
||||
.link-box .search { margin-top: 12px; max-width: 420px; }
|
||||
.sub { font-size: 0.74rem; color: var(--text-faint); font-weight: 600; margin-top: 12px; }
|
||||
|
||||
/* History drawer */
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: min(400px, 100vw); z-index: 20;
|
||||
background: var(--surface-0); border-left: 1px solid var(--surface-3);
|
||||
box-shadow: -18px 0 40px #0008; display: flex; flex-direction: column;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.drawer { animation: drawer-in 0.32s var(--ease-out); }
|
||||
@keyframes drawer-in { from { translate: 24px 0; opacity: 0; } }
|
||||
}
|
||||
.drawer header { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px 12px; }
|
||||
.drawer h2 { font-size: 1rem; font-weight: 650; }
|
||||
.drawer ol { list-style: none; overflow-y: auto; padding: 0 20px 24px; }
|
||||
.drawer li { border-top: 1px solid var(--surface-3); padding: 12px 0; display: grid; gap: 6px; }
|
||||
.drawer .when { display: flex; justify-content: space-between; gap: 10px; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.drawer .who { color: var(--text); font-weight: 550; }
|
||||
.drawer dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 10px; font-size: 0.8rem; }
|
||||
.drawer dt { color: var(--text-faint); }
|
||||
.drawer dd { overflow-wrap: anywhere; }
|
||||
.drawer dd s { color: var(--text-faint); text-decoration-color: #8d87c299; }
|
||||
.drawer .arrow { color: var(--text-faint); margin: 0 4px; }
|
||||
|
||||
/* Placeholder pages */
|
||||
.page { padding: 28px 32px; max-width: 60ch; }
|
||||
.page h1 { font-size: 1.3rem; font-weight: 650; margin-bottom: 8px; }
|
||||
.page p { color: var(--text-muted); }
|
||||
.boot { height: 100%; display: grid; place-items: center; background: var(--surface-0); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 1180px) {
|
||||
.library { grid-template-columns: 320px minmax(0, 1fr); }
|
||||
.det-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.side { flex-direction: row; align-items: center; gap: 4px; padding: 10px 12px; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--surface-3); scrollbar-width: none; }
|
||||
.side::-webkit-scrollbar { display: none; }
|
||||
.side .logo { margin: 0 10px 0 4px; }
|
||||
.side nav { flex-direction: row; }
|
||||
.side .grp, .side .src-nav, .side-foot .who { display: none; }
|
||||
.side-foot { margin: 0 0 0 auto; padding: 0 4px; }
|
||||
.side-foot button { white-space: nowrap; }
|
||||
.library { grid-template-columns: 1fr; }
|
||||
.library.has-selection .list-pane { display: none; }
|
||||
.library:not(.has-selection) .detail-pane { display: none; }
|
||||
.list-pane { border-right: 0; }
|
||||
.back { display: inline-flex; margin: 0 0 14px -8px; }
|
||||
.detail { padding: 16px 16px 40px; }
|
||||
.det-head { grid-template-columns: 84px minmax(0, 1fr); gap: 14px; }
|
||||
.cover.large { width: 84px; height: 122px; }
|
||||
.det-head h1 { font-size: 1.2rem; }
|
||||
.det-tools { margin-left: 0; }
|
||||
.alert { flex-direction: column; align-items: stretch; }
|
||||
.box form .two { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ApiError, request } from "../src/api.ts";
|
||||
|
||||
function fakeFetch(res: Response | (() => never)) {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fn = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
if (typeof res === "function") return res();
|
||||
return res.clone();
|
||||
}) as unknown as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
describe("request", () => {
|
||||
test("throws an ApiError carrying code on a non-2xx JSON error", async () => {
|
||||
const { fn } = fakeFetch(
|
||||
new Response(JSON.stringify({ error: { code: "validation", message: "title: required" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = (await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("validation");
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.message).toBe("title: required");
|
||||
});
|
||||
|
||||
test("non-GET requests send the CSRF header, JSON and same-origin cookies", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "e1" }), { status: 201 }));
|
||||
const out = await request<{ id: string }>("POST", "entries/quick", { body: { title: "Dune" }, fetchImpl: fn });
|
||||
expect(out).toEqual({ id: "e1" });
|
||||
const { url, init } = calls[0]!;
|
||||
expect(url).toBe("/api/v1/entries/quick");
|
||||
const h = new Headers(init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
expect(init.credentials).toBe("same-origin");
|
||||
expect(init.body).toBe(JSON.stringify({ title: "Dune" }));
|
||||
});
|
||||
|
||||
test("login and register also send the CSRF header and JSON content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "u1" })));
|
||||
await request("POST", "auth/login", { body: { username: "a", password: "b" }, fetchImpl: fn });
|
||||
await request("POST", "history/h1/restore", { fetchImpl: fn });
|
||||
for (const c of calls) {
|
||||
const h = new Headers(c.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
}
|
||||
});
|
||||
|
||||
test("multipart uploads send the CSRF header but let fetch set the content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "a1" }), { status: 201 }));
|
||||
const form = new FormData();
|
||||
form.set("entity", "entry");
|
||||
await request("POST", "attachments", { form, fetchImpl: fn });
|
||||
const h = new Headers(calls[0]!.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBeNull();
|
||||
expect(calls[0]!.init.body).toBe(form);
|
||||
});
|
||||
|
||||
test("GET builds the query string and skips empty values", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ items: [] })));
|
||||
await request("GET", "entries", { query: { q: "dune", status: undefined, draft: true, limit: 50 }, fetchImpl: fn });
|
||||
expect(calls[0]!.url).toBe("/api/v1/entries?q=dune&draft=true&limit=50");
|
||||
});
|
||||
|
||||
test("204 resolves to undefined", async () => {
|
||||
const { fn } = fakeFetch(new Response(null, { status: 204 }));
|
||||
expect(await request("DELETE", "engagements/x", { fetchImpl: fn })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a non-JSON error still becomes an ApiError", async () => {
|
||||
const { fn } = fakeFetch(new Response("<html>Bad gateway</html>", { status: 502 }));
|
||||
const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("http_502");
|
||||
expect(err.status).toBe(502);
|
||||
});
|
||||
|
||||
test("a network failure becomes an ApiError with code network", async () => {
|
||||
const { fn } = fakeFetch(() => {
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("network");
|
||||
expect(err.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { addTag, applyEntryPatch, orderEngagements, removeTag, setCustomValue } from "../src/edits.ts";
|
||||
|
||||
describe("entry edit builders", () => {
|
||||
test("two quick tag adds built from the latest cache keep both", () => {
|
||||
// Each edit reads the cache as left by the previous (optimistic) edit, not the render's props.
|
||||
let cache = { tags: [] as string[], rating: null as number | null };
|
||||
const edit = (build: (cur: typeof cache) => Partial<typeof cache>) => (cache = applyEntryPatch(cache, build(cache)));
|
||||
edit((cur) => ({ tags: addTag(cur.tags, "cosmic horror") }));
|
||||
edit((cur) => ({ tags: addTag(cur.tags, "re-read") }));
|
||||
expect(cache.tags).toEqual(["cosmic horror", "re-read"]);
|
||||
});
|
||||
|
||||
test("addTag trims and ignores case duplicates; removeTag removes one", () => {
|
||||
expect(addTag(["Horror"], " horror ")).toEqual(["Horror"]);
|
||||
expect(addTag(["a"], " ")).toEqual(["a"]);
|
||||
expect(removeTag(["a", "b"], "a")).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("setCustomValue sets, and removes empty values", () => {
|
||||
expect(setCustomValue({ a: 1 }, "b", "x")).toEqual({ a: 1, b: "x" });
|
||||
expect(setCustomValue({ a: 1, b: "x" }, "b", "")).toEqual({ a: 1 });
|
||||
expect(setCustomValue({ a: [1] }, "a", [])).toEqual({});
|
||||
expect(setCustomValue({ a: 1 }, "a", null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderEngagements", () => {
|
||||
const g = (id: string, createdAt: string, finished?: string, started?: string) => ({
|
||||
id,
|
||||
createdAt,
|
||||
finishedAt: finished ? { value: finished, precision: "day" as const } : null,
|
||||
startedAt: started ? { value: started, precision: "day" as const } : null,
|
||||
});
|
||||
test("sorts by finishedAt ?? startedAt, then createdAt; undated first", () => {
|
||||
const list = [
|
||||
g("late", "2026-01-01T00:00:00Z", "2026-09-14"),
|
||||
g("early-logged-later", "2026-10-01T00:00:00Z", "2025-03"),
|
||||
g("started-only", "2026-02-01T00:00:00Z", undefined, "2025-06-01"),
|
||||
g("undated", "2026-03-01T00:00:00Z"),
|
||||
];
|
||||
expect(orderEngagements(list).map((x) => x.id)).toEqual(["undated", "early-logged-later", "started-only", "late"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
expect(formatRating(90, "5half")).toBe("★★★★½");
|
||||
expect(formatRating(100, "5half")).toBe("★★★★★");
|
||||
expect(formatRating(80, "5half")).toBe("★★★★☆");
|
||||
expect(formatRating(0, "5half")).toBe("☆☆☆☆☆");
|
||||
});
|
||||
test("null is empty on every scale", () => {
|
||||
expect(formatRating(null, "5half")).toBe("");
|
||||
expect(formatRating(null, "10")).toBe("");
|
||||
expect(formatRating(undefined, "100")).toBe("");
|
||||
});
|
||||
test("whole stars, 10 and 100 point scales", () => {
|
||||
expect(formatRating(90, "5")).toBe("★★★★★");
|
||||
expect(formatRating(70, "5")).toBe("★★★★☆");
|
||||
expect(formatRating(90, "10")).toBe("9/10");
|
||||
expect(formatRating(85, "10")).toBe("8.5/10");
|
||||
expect(formatRating(73, "100")).toBe("73/100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPartialDate", () => {
|
||||
test("renders by precision", () => {
|
||||
expect(formatPartialDate({ value: "2025-03", precision: "month" })).toBe("Mar 2025");
|
||||
expect(formatPartialDate({ value: "2019", precision: "year" })).toBe("2019");
|
||||
expect(formatPartialDate({ value: "2026-09-14", precision: "day" })).toBe("14 Sep 2026");
|
||||
expect(formatPartialDate({ value: "2026-09-04T23:30:00-05:00", precision: "day" })).toBe("4 Sep 2026");
|
||||
expect(formatPartialDate({ value: "", precision: "unknown" })).toBe("");
|
||||
expect(formatPartialDate(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateRange", () => {
|
||||
test("flags dates that are not exact", () => {
|
||||
expect(formatDateRange(null, { value: "2025-03", precision: "month" })).toBe("Mar 2025 (no exact date)");
|
||||
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, { value: "2026-09-14", precision: "day" })).toBe(
|
||||
"2 Sep – 14 Sep 2026",
|
||||
);
|
||||
expect(formatDateRange({ value: "2025-12-30", precision: "day" }, { value: "2026-01-02", precision: "day" })).toBe(
|
||||
"30 Dec 2025 – 2 Jan 2026",
|
||||
);
|
||||
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, null)).toBe("Started 2 Sep 2026");
|
||||
expect(formatDateRange(null, null)).toBe("No dates");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePartialDate", () => {
|
||||
test("accepts year, month and day, rejects the rest", () => {
|
||||
expect(parsePartialDate("2019")).toEqual({ value: "2019", precision: "year" });
|
||||
expect(parsePartialDate(" 2025-03 ")).toEqual({ value: "2025-03", precision: "month" });
|
||||
expect(parsePartialDate("2024-02-29")).toEqual({ value: "2024-02-29", precision: "day" });
|
||||
expect(parsePartialDate("2023-02-29")).toBeNull();
|
||||
expect(parsePartialDate("03/04/2019")).toBeNull();
|
||||
expect(parsePartialDate("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollupLabel", () => {
|
||||
test("children roll up as 'season 3 of 5'", () => {
|
||||
expect(rollupLabel("show", { total: 5, completed: 3 })).toBe("season 3 of 5");
|
||||
expect(rollupLabel("show", { total: 5, completed: 0 })).toBe("5 seasons");
|
||||
expect(rollupLabel("podcast", { total: 200, completed: 200 })).toBe("all 200 episodes");
|
||||
expect(rollupLabel("book", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("friendlyError", () => {
|
||||
const v = (message: string, status = 400) => ({ status, code: "validation", message });
|
||||
test("maps validation reasons to plain copy", () => {
|
||||
expect(friendlyError(v("customFields.Website: not_url"))).toBe("Website needs a full web address, starting with https://.");
|
||||
expect(friendlyError(v("finishedAt: not_date"))).toBe("Finished needs a date like 2025, 2025-03 or 2025-03-14.");
|
||||
expect(friendlyError(v("rating: out_of_range"))).toBe("Pick a rating between 0 and 5 stars.");
|
||||
expect(friendlyError(v("rating: out_of_range"), { ratingScale: "10" })).toBe("Pick a rating between 0 and 10.");
|
||||
expect(friendlyError(v("customFields.Mood: out_of_range"), { ratingScale: "5" })).toBe("Pick a rating for Mood between 0 and 5 stars.");
|
||||
expect(friendlyError(v("title: required"))).toBe("Title can't be empty.");
|
||||
expect(friendlyError(v("review: too_long"))).toBe("Review is too long. Shorten it and try again.");
|
||||
expect(friendlyError(v("password: Too small: expected string to have >=8 characters"))).toBe("Passwords need at least 8 characters.");
|
||||
expect(friendlyError(v("title: Too big: expected string to have <=1000 characters"))).toBe("Title is too long. Shorten it and try again.");
|
||||
expect(friendlyError(v("mime: unsupported file type"))).toBe("That file type isn't supported. Use an image, a PDF or a text file.");
|
||||
expect(friendlyError(v("customFields.Mood: unknown_option"))).toBe("Mood has an option that no longer exists. Pick one from the list.");
|
||||
expect(friendlyError(v("displayName: weird_reason"))).toBe("Display name wasn't accepted. Check it and try again.");
|
||||
});
|
||||
test("413 means the file is too large", () => {
|
||||
expect(friendlyError({ status: 413, code: "validation", message: "Request body is too large (max 50 MB)." })).toBe("That file is too large. The limit is 25 MB.");
|
||||
expect(friendlyError(v("file: larger than 25 MB"))).toBe("That file is too large. The limit is 25 MB.");
|
||||
});
|
||||
test("other API errors keep the server's message; unknown errors get a generic line", () => {
|
||||
expect(friendlyError({ status: 401, code: "unauthenticated", message: "Wrong username or password." })).toBe("Wrong username or password.");
|
||||
expect(friendlyError(new Error("boom"))).toBe("Something went wrong. Try again.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shortDate", () => {
|
||||
expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct");
|
||||
});
|
||||
|
||||
describe("review draft sync", () => {
|
||||
const draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text });
|
||||
const pick = (d: Draft) => [d.text, d.synced, d.pending];
|
||||
|
||||
test("a rollback after a failed save keeps the local text", () => {
|
||||
const save = { was: "First thoughts.", stored: "Second thoughts." };
|
||||
let d = draftStartSave(draft("Second thoughts.", save.was), save);
|
||||
d = draftIncoming(d, save.stored); // optimistic update
|
||||
d = draftIncoming(d, save.was); // rollback
|
||||
d = draftSettled(d, save, false);
|
||||
expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]);
|
||||
});
|
||||
|
||||
// Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does.
|
||||
const A = { was: "old", stored: "a" };
|
||||
const B = { was: "a", stored: "b" };
|
||||
const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b");
|
||||
|
||||
test("two overlapping saves both settle and the flag clears (success, success)", () => {
|
||||
let d = twoSaves();
|
||||
expect(d.pending).toBe(2);
|
||||
d = draftSettled(draftSettled(d, A, true), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
expect(d.echoes).toEqual([]);
|
||||
});
|
||||
|
||||
test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => {
|
||||
let d = draftIncoming(twoSaves(), "old"); // A's rollback
|
||||
d = draftSettled(draftSettled(d, A, false), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
let e = draftIncoming(twoSaves(), "a"); // B's rollback
|
||||
e = draftSettled(draftSettled(e, A, true), B, false);
|
||||
expect(pick(e)).toEqual(["b", "a", 0]);
|
||||
});
|
||||
|
||||
test("an incoming value that arrives while saving is applied once saving ends", () => {
|
||||
const save = { was: "x", stored: "y" };
|
||||
let d = draftStartSave(draft("y", "x"), save);
|
||||
d = draftIncoming(d, "Restored.");
|
||||
expect(d.text).toBe("y");
|
||||
d = draftSettled(d, save, true);
|
||||
expect(pick(d)).toEqual(["Restored.", "Restored.", 0]);
|
||||
});
|
||||
|
||||
test("a change from elsewhere replaces untouched text but not edited text", () => {
|
||||
expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First.");
|
||||
expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed.");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ user layer or files a Changes-inbox event.
|
||||
| Path | Responsibility | State |
|
||||
|---|---|---|
|
||||
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
|
||||
| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
|
||||
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
||||
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||
|
||||
@@ -4,6 +4,32 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin
|
||||
One entry per decision a future maintainer would otherwise have to reverse-engineer.
|
||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
||||
|
||||
## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts
|
||||
**Status:** accepted
|
||||
**Context:** Task 17 and its fix round 1 (controller rulings).
|
||||
**Decision:**
|
||||
- **Three small API routes for the web app.** `GET auth/status` (no auth) → `{ hasUsers }`: `/login` redirects a fresh
|
||||
instance to `/register`, which shows "Create the admin account", or invite mode when users exist (also on
|
||||
`invite_required`, and with `?invite=` prefilled). `GET attachments?entity=&entityId=` lists an owned record's
|
||||
attachments with their links (404 when the record is not the caller's). `GET/PATCH me/preferences` →
|
||||
`{ ratingScale: "5half"|"5"|"10"|"100", defaultVisibility }`, stored in `users.preferences` under the same keys
|
||||
`defaultVisibility()` already reads (`packages/core/src/preferences.ts`); unknown stored keys are kept.
|
||||
- **Rating scale** comes from preferences (default five stars with halves). Stored ratings stay integers 0–100; the web
|
||||
formats them with `formatRating(value, scale)`.
|
||||
- **Entry edits are optimistic and serial.** `useEntryPatch` updates the cached entry in `onMutate` (rollback on error),
|
||||
runs one entry's PATCHes in order through a TanStack mutation `scope`, and `edit(cur => patch)` builds from the latest
|
||||
cache, so two quick tag adds keep both. Only the last queued edit writes the server's answer back.
|
||||
- **Keyboard rating saves once:** arrow steps update locally and save 500 ms after the last key, on blur, or on unmount,
|
||||
so stepping does not write a history row per keypress.
|
||||
- **Errors in the product's voice:** every error display goes through `friendlyError()` (`apps/web/src/format.ts`), which maps
|
||||
`validation` field/reason pairs and 413 to plain copy; other API errors keep the server's message.
|
||||
- **AA contrast on the approved palette:** dark text on `--warn`, `--text-faint` group labels, `--text-muted` placeholders on
|
||||
`--surface-3` (recorded in DESIGN.md).
|
||||
- **Fonts are vendored** (Inter woff2 from fontsource; Gelasio as the official variable TTFs from github.com/google/fonts
|
||||
`ofl/gelasio`, unmodified, because no woff2 tooling was available offline) with their OFL texts.
|
||||
- **The web app mirrors wire types** in `apps/web/src/api.ts` instead of importing `@sttracker/core` (apps/web has no
|
||||
workspace dependency on it, and the browser bundle must not pull server code).
|
||||
|
||||
## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write
|
||||
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
|
||||
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
|
||||
|
||||
@@ -18,6 +18,9 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
|
||||
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
||||
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
|
||||
- [ ] Web: convert the vendored Gelasio variable TTFs (~170 KB each) to latin-subset woff2 once fonttools/woff2 is available; Bun inlines fonts into the CSS bundle.
|
||||
- [ ] Web/API: "Linked sources" is inferred from `plugin:` actors in `fieldSources`, so a source that matched an entry but never wrote a field is not shown. Add a per-entry source-links route.
|
||||
- [ ] Web: the detail pane only lists attachments of the entry itself, not those linked to its reads or copies.
|
||||
|
||||
## Someday
|
||||
|
||||
@@ -32,6 +35,7 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
file, and compare steps and token use.
|
||||
|
||||
## Known issues
|
||||
- [ ] Web: in production the bundled HTML references `/../../chunk-*.css|js` (Bun bundles `../../web/index.html` from `apps/server`). Browsers normalise it to `/chunk-*` and it loads; check it behind a reverse proxy.
|
||||
- [ ] Plugins: reload re-imports entry files only; edits to a plugin's other modules need a server restart.
|
||||
- [ ] Plugins: rate limiting is per run (each run builds its own client), not per plugin (spec §11); share a limiter per plugin in Phase 2.
|
||||
- [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.
|
||||
|
||||
@@ -111,6 +111,11 @@ export async function login(
|
||||
return { user: toUser(row), sessionToken };
|
||||
}
|
||||
|
||||
/** True once any account exists (the first one becomes admin). */
|
||||
export async function hasUsers(db: Db): Promise<boolean> {
|
||||
return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined;
|
||||
}
|
||||
|
||||
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("sessions")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import type { Executor } from "./history.ts";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import { nowIso } from "@sttracker/db/ids";
|
||||
import type { Visibility } from "@sttracker/db/schema";
|
||||
import { VISIBILITIES } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
|
||||
export const RATING_SCALES = ["5half", "5", "10", "100"] as const;
|
||||
export type RatingScale = (typeof RATING_SCALES)[number];
|
||||
|
||||
/** Per-user settings, stored as JSON in `users.preferences`. Keys match what `defaultVisibility()` reads. */
|
||||
export interface Preferences {
|
||||
ratingScale: RatingScale;
|
||||
defaultVisibility: Visibility;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" };
|
||||
|
||||
async function readRaw(k: Executor, userId: string, lock = false): Promise<Record<string, unknown>> {
|
||||
let q = k.selectFrom("users").select("preferences").where("id", "=", userId);
|
||||
if (lock) q = q.forUpdate();
|
||||
const u = await q.executeTakeFirst();
|
||||
if (!u) throw new NotFoundError("user");
|
||||
return fromJson<Record<string, unknown>>(u.preferences, {});
|
||||
}
|
||||
|
||||
/** The user's preferences; missing or invalid stored values fall back to the defaults. */
|
||||
export async function getPreferences(db: Db, userId: string): Promise<Preferences> {
|
||||
return toPreferences(await readRaw(db.k, userId));
|
||||
}
|
||||
|
||||
function toPreferences(raw: Record<string, unknown>): Preferences {
|
||||
return {
|
||||
ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale,
|
||||
defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility)
|
||||
? (raw.defaultVisibility as Visibility)
|
||||
: DEFAULT_PREFERENCES.defaultVisibility,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge a partial update into the stored preferences (unknown stored keys are kept). */
|
||||
export async function updatePreferences(db: Db, userId: string, patch: Partial<Preferences>): Promise<Preferences> {
|
||||
if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid");
|
||||
if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid");
|
||||
// Read-modify-write in one transaction, with the user row locked on Postgres (SQLite runs one
|
||||
// connection), so concurrent updates of different keys don't drop each other.
|
||||
return db.k.transaction().execute(async (tx) => {
|
||||
const raw = await readRaw(tx, userId, db.dialect === "postgres");
|
||||
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
|
||||
await tx.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
|
||||
return toPreferences(next);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user