diff --git a/DESIGN.md b/DESIGN.md index ad90c82..cc23c17 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 03c31a1..245491c 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -3,7 +3,7 @@ * `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, Status } from "./format.ts"; +import type { PartialDate, RatingScale, Status } from "./format.ts"; export class ApiError extends Error { constructor( @@ -138,6 +138,11 @@ export interface Attachment { size: number; } +export interface Preferences { + ratingScale: RatingScale; + defaultVisibility: "private" | "friends" | "public"; +} + export interface Connection { id: string; pluginId: string; @@ -206,7 +211,10 @@ export interface EntryQuery { } export const api = { + authStatus: () => request<{ hasUsers: boolean }>("GET", "auth/status"), me: () => request("GET", "me"), + preferences: () => request("GET", "me/preferences"), + updatePreferences: (patch: Partial) => request("PATCH", "me/preferences", { body: patch }), login: (username: string, password: string) => request("POST", "auth/login", { body: { username, password } }), register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) => request("POST", "auth/register", { body }), @@ -228,6 +236,8 @@ export const api = { copies: (workId: string) => request("GET", "copies", { query: { workId } }), addCopy: (body: Partial>) => request("POST", "copies", { body }), + attachments: (entity: "entry" | "engagement" | "copy", entityId: string) => + request("GET", "attachments", { query: { entity, entityId } }), upload: (file: File, entityId: string) => { const form = new FormData(); form.set("file", file); diff --git a/apps/web/src/components/DetailBoxes.tsx b/apps/web/src/components/DetailBoxes.tsx index 7dd1e66..304c111 100644 --- a/apps/web/src/components/DetailBoxes.tsx +++ b/apps/web/src/components/DetailBoxes.tsx @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState, type FormEvent, type ReactNode } from "react"; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { Link } from "wouter"; -import { ApiError, api, type Attachment, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts"; +import { api, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts"; +import { orderEngagements, setCustomValue } from "../edits.ts"; import { actorConnectionId, describeRating, @@ -9,6 +10,7 @@ import { formatDateRange, formatPartialDate, formatRating, + friendlyError, ordinal, parsePartialDate, rollupLabel, @@ -33,7 +35,6 @@ function Box({ title, id, action, wide, className, children }: { title: string; ); } -const errText = (e: unknown) => (e instanceof ApiError ? e.message : "That didn't save. Try again."); // --------------------------------------------------------------------------------------------- // Reads / plays @@ -63,9 +64,14 @@ export function EngagementsBox({ setAdding(false); refresh(); }, - onError: (e) => setFormError(errText(e)), + onError: (e) => setFormError(friendlyError(e)), + }); + const [removeError, setRemoveError] = useState(null); + const remove = useMutation({ + mutationFn: api.deleteEngagement, + onSuccess: () => (setRemoveError(null), refresh()), + onError: (e) => setRemoveError(friendlyError(e)), }); - const remove = useMutation({ mutationFn: api.deleteEngagement, onSuccess: refresh }); function submit(e: FormEvent) { e.preventDefault(); @@ -84,7 +90,7 @@ export function EngagementsBox({ } // Oldest is the 1st; show newest first, like the mockup. - const ordered = [...(engagements ?? [])].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); + const ordered = orderEngagements(engagements ?? []); const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse(); return ( @@ -120,6 +126,7 @@ export function EngagementsBox({ )) )} + {removeError &&

{removeError}

} {adding && (
@@ -170,7 +177,7 @@ export function CopyBox({ entry }: { entry: EntryView }) { setAdding(false); void qc.invalidateQueries({ queryKey: ["copies", entry.workId] }); }, - onError: (e) => setFormError(errText(e)), + onError: (e) => setFormError(friendlyError(e)), }); function submit(e: FormEvent) { @@ -255,21 +262,18 @@ export function CopyBox({ entry }: { entry: EntryView }) { // Attachments // --------------------------------------------------------------------------------------------- -// The API has no "list attachments of an entry" route yet, so this shows what was uploaded in this session. -const uploaded = new Map(); - export function AttachmentsBox({ entry }: { entry: EntryView }) { - const [items, setItems] = useState(() => uploaded.get(entry.id) ?? []); + const qc = useQueryClient(); + const list = useQuery({ queryKey: ["attachments", entry.id], queryFn: () => api.attachments("entry", entry.id) }); + const items = list.data ?? []; const [error, setError] = useState(null); const upload = useMutation({ mutationFn: (file: File) => api.upload(file, entry.id), - onSuccess: (a) => { - const next = [...items, a]; - uploaded.set(entry.id, next); - setItems(next); + onSuccess: () => { setError(null); + void qc.invalidateQueries({ queryKey: ["attachments", entry.id] }); }, - onError: (e) => setError(e instanceof ApiError && e.code === "validation" ? "That file type isn't supported. Use an image, a PDF or a text file." : errText(e)), + onError: (e) => setError(friendlyError(e)), }); return ( @@ -309,7 +313,8 @@ export function AttachmentsBox({ entry }: { entry: EntryView }) {
{error &&

{error}

} - {items.length === 0 && !error &&

Screenshots, photos of your copy, receipts.

} + {list.error &&

{friendlyError(list.error)}

} + {items.length === 0 && !error && !list.isPending &&

Screenshots, photos of your copy, receipts.

} ); } @@ -353,12 +358,11 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 }); const patch = useEntryPatch(entry.id); if (!defs.data?.length) return null; - const save = (def: CustomFieldDef, value: unknown) => { - const next = { ...entry.customFields }; - if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) delete next[def.id]; - else next[def.id] = value; - if (JSON.stringify(next) !== JSON.stringify(entry.customFields)) patch.mutate({ customFields: next }); - }; + 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 (
@@ -366,7 +370,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin save(d, v)} /> ))}
- {patch.error &&

{errText(patch.error)}

} + {patch.error &&

{friendlyError(patch.error)}

}
); } @@ -468,7 +472,7 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {c.work.title} - {statusLabel(c.status, c.work.mediaType)} + {statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)} {c.rating !== null ? ( {formatRating(c.rating, scale)} @@ -490,9 +494,20 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) export function ReviewBox({ entry }: { entry: EntryView }) { const patch = useEntryPatch(entry.id); const [saved, setSaved] = useState(false); - const save = (v: string) => { - const next = v.trim() === "" ? null : v; - if (next === entry.review) return; + const [text, setText] = useState(entry.review ?? ""); + // The server value this text was last in sync with. When the review changes elsewhere (Restore, + // another tab) and the user has not typed since, show the new value, so a later blur can't undo it. + const synced = useRef(entry.review ?? ""); + useEffect(() => { + const incoming = entry.review ?? ""; + if (incoming === synced.current) return; + setText((cur) => (cur === synced.current ? incoming : cur)); + synced.current = incoming; + }, [entry.review]); + const save = () => { + const next = text.trim() === "" ? null : text; + if ((next ?? "") === (entry.review ?? "")) return; + synced.current = next ?? ""; patch.mutate({ review: next }, { onSuccess: () => setSaved(true) }); }; return ( @@ -506,15 +521,15 @@ export function ReviewBox({ entry }: { entry: EntryView }) {