fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
- Tags and custom fields update the cached entry optimistically and build from the latest cache; one entry's PATCHes run in order (mutation scope). - The review textarea follows server changes (Restore) unless edited. - friendlyError() maps validation reasons and 413 to plain copy everywhere. - Rating scale from me/preferences; /login and /register use auth/status; Attachments lists from the API. - Gelasio vendored (official OFL variable TTFs); minors from the review. - Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d204775235
commit
5fa59310f3
@@ -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)
|
||||
|
||||
+11
-1
@@ -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<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 }),
|
||||
@@ -228,6 +236,8 @@ export const api = {
|
||||
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);
|
||||
|
||||
@@ -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<string | null>(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<HTMLFormElement>) {
|
||||
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({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{removeError && <p className="error-text" role="alert">{removeError}</p>}
|
||||
{adding && (
|
||||
<form onSubmit={submit} aria-label={nouns.log}>
|
||||
<div className="two">
|
||||
@@ -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<HTMLFormElement>) {
|
||||
@@ -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<string, Attachment[]>();
|
||||
|
||||
export function AttachmentsBox({ entry }: { entry: EntryView }) {
|
||||
const [items, setItems] = useState<Attachment[]>(() => 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<string | null>(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 (
|
||||
<Box title="Attachments" id="box-attachments">
|
||||
@@ -309,7 +313,8 @@ export function AttachmentsBox({ entry }: { entry: EntryView }) {
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
{items.length === 0 && !error && <p className="hint">Screenshots, photos of your copy, receipts.</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box title="Details" id="box-custom" wide>
|
||||
<div className="custom">
|
||||
@@ -366,7 +370,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
|
||||
<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">{errText(patch.error)}</p>}
|
||||
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +472,7 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
|
||||
<Link href={`/entries/${c.id}`}>
|
||||
<span className="t">
|
||||
{c.work.title}
|
||||
<span className="s">{statusLabel(c.status, c.work.mediaType)}</span>
|
||||
<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>
|
||||
@@ -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 }) {
|
||||
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
|
||||
<textarea
|
||||
id="review-text"
|
||||
defaultValue={entry.review ?? ""}
|
||||
value={text}
|
||||
placeholder="What stayed with you? Markdown works here."
|
||||
onChange={() => setSaved(false)}
|
||||
onBlur={(e) => save(e.target.value)}
|
||||
onChange={(e) => (setText(e.target.value), setSaved(false))}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save(e.currentTarget.value);
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
|
||||
}}
|
||||
/>
|
||||
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
|
||||
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { engagementNouns, formatPartialDate, mediaTypeLabel, STATUSES, statusLabel } from "../format.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";
|
||||
@@ -23,7 +24,7 @@ export function EntryDetail({ id }: { id: string }) {
|
||||
<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." : entry.error.message}
|
||||
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -51,8 +52,7 @@ function Detail({ entry }: { entry: EntryView }) {
|
||||
const meta = [mediaTypeLabel(type)];
|
||||
if (entry.work.year) meta.push(String(entry.work.year));
|
||||
if (finished.length) {
|
||||
const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
|
||||
meta.push(`${past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
|
||||
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)}`);
|
||||
}
|
||||
@@ -91,8 +91,12 @@ function Detail({ entry }: { entry: EntryView }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tags entry={entry} onChange={(tags) => patch.mutate({ tags })} />
|
||||
{patch.error && <p className="error-text" role="alert">{patch.error.message}</p>}
|
||||
<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)}</p>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -141,26 +145,18 @@ function whoChanged(pending: { connectionId: string | null }[], sources: { id: s
|
||||
return ids.size > 1 ? "Your sources" : "A source";
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
function shortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
|
||||
}
|
||||
|
||||
function Tags({ entry, onChange }: { entry: EntryView; onChange: (tags: string[]) => void }) {
|
||||
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
function add() {
|
||||
const t = draft.trim();
|
||||
if (!t) return;
|
||||
if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
|
||||
if (draft.trim()) onAdd(draft);
|
||||
setDraft("");
|
||||
}
|
||||
return (
|
||||
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
||||
{entry.tags.map((t) => (
|
||||
{tags.map((t) => (
|
||||
<li key={t} className="pill">
|
||||
{t}
|
||||
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onChange(entry.tags.filter((x) => x !== t))}>
|
||||
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, statusLabel, timeAgo, type PartialDate, type Status } from "../format.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";
|
||||
@@ -52,7 +52,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
||||
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.work.mediaType);
|
||||
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";
|
||||
@@ -74,7 +74,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
||||
</button>
|
||||
</header>
|
||||
{history.isPending ? null : history.error ? (
|
||||
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{history.error.message}</p>
|
||||
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{friendlyError(history.error)}</p>
|
||||
) : (
|
||||
<ol>
|
||||
{history.data.map((h) => {
|
||||
@@ -109,7 +109,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{restore.error.message}</p>}
|
||||
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{friendlyError(restore.error)}</p>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { api, type EntryView, type Work } from "../api.ts";
|
||||
import { mediaTypeLabel } from "../format.ts";
|
||||
import { friendlyError, mediaTypeLabel } from "../format.ts";
|
||||
import { useDebounced } from "../hooks.ts";
|
||||
import { Cover } from "./Cover.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
@@ -96,7 +96,7 @@ export function LinkToCatalogue({ entry }: { entry: EntryView }) {
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
{error && <p className="error-text" role="alert">{error.message}</p>}
|
||||
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 (
|
||||
@@ -25,10 +26,12 @@ export function Shell({ children }: { children: ReactNode }) {
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
qc.clear();
|
||||
navigate("/login", { replace: true });
|
||||
} catch {
|
||||
// The session may already be gone; signing out locally is still right.
|
||||
}
|
||||
qc.clear();
|
||||
resetLibraryState();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,8 +46,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
||||
<NavLink href="/changes" active={at("/changes")}>
|
||||
Changes
|
||||
{pending > 0 && (
|
||||
<span className="count" aria-label={`${pending} waiting`}>
|
||||
<span className="count">
|
||||
{changes.data?.nextCursor ? `${pending}+` : pending}
|
||||
<span className="sr-only"> waiting</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -54,7 +58,7 @@ export function Shell({ children }: { children: ReactNode }) {
|
||||
{(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"}`} aria-label={s.status === "active" ? "Active" : "Paused"} />
|
||||
<span className={`dot${s.status === "active" ? "" : " paused"}`} role="img" aria-label={s.status === "active" ? "Active" : "Paused"} />
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink href="/sources" active={path === "/sources"}>
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
+72
-8
@@ -9,7 +9,7 @@ export interface PartialDate {
|
||||
/** How ratings are shown and entered. Stored ratings are always integers 0–100. */
|
||||
export type RatingScale = "5half" | "5" | "10" | "100";
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
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 {
|
||||
@@ -160,14 +160,20 @@ export function statusLabel(status: Status, type = "other"): string {
|
||||
}
|
||||
|
||||
/** What one time through a work is called: reads, plays, watches, listens. */
|
||||
export function engagementNouns(type: string): { box: string; one: string; verb: string; log: string } {
|
||||
if (["book", "comic", "manga"].includes(type)) return { box: "Reads", one: "read", verb: "Reading", log: "Log a read" };
|
||||
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", log: "Log a play" };
|
||||
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", log: "Log a watch" };
|
||||
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", log: "Log a listen" };
|
||||
return { box: "Times through", one: "time", verb: "In progress", log: "Log a time" };
|
||||
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". */
|
||||
@@ -189,8 +195,66 @@ export function timeAgo(iso: string, now: Date = new Date()): string {
|
||||
return `${t.getDate()} ${MONTHS[t.getMonth()]} ${t.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** "Hardcover" from actor "plugin:hardcover:<id>", for history rows. */
|
||||
/** 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.
|
||||
*/
|
||||
export function friendlyError(err: unknown): 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") return `${label} is out of range. Ratings go from 0 to 100.`;
|
||||
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.`;
|
||||
}
|
||||
|
||||
+33
-5
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ChangeEvent, type EntryPatch } from "./api.ts";
|
||||
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() {
|
||||
@@ -31,16 +32,43 @@ export function useDebounced<T>(value: T, ms = 220): T {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Shared save path for every detail edit: PATCH, then refresh the entry and the list. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useEntryPatch(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
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) => {
|
||||
qc.setQueryData(["entry", id], 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: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
||||
},
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -35,7 +36,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
||||
<div className="auth-card" role="alert">
|
||||
<span className="logo">STTracker</span>
|
||||
<h1>Can't load your library</h1>
|
||||
<p className="lede">{me.error.message}</p>
|
||||
<p className="lede">{friendlyError(me.error)}</p>
|
||||
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ApiError, api, type EntryView } from "../api.ts";
|
||||
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, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
|
||||
import { describeRating, formatRating, friendlyError, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
|
||||
import { useDebounced, usePendingChanges } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
@@ -34,7 +34,13 @@ export function Library({ selectedId }: { selectedId: string | null }) {
|
||||
}
|
||||
|
||||
// Filters survive navigating between entries (the list pane remounts on route change).
|
||||
let savedFilters: { q: string; mediaType?: string; status?: Status; draft: boolean } = { q: "", draft: false };
|
||||
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();
|
||||
@@ -57,22 +63,24 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
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(e instanceof ApiError ? e.message : "Couldn't add that. Try again."),
|
||||
onError: (e) => setQuickError(friendlyError(e)),
|
||||
});
|
||||
function onQuick(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
|
||||
const title = input.value.trim();
|
||||
const title = (quickInput.current?.value ?? "").trim();
|
||||
if (!title || quick.isPending) return;
|
||||
setQuickError(null);
|
||||
quick.mutate(title, { onSuccess: () => (input.value = "") });
|
||||
quick.mutate(title);
|
||||
}
|
||||
|
||||
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
@@ -98,12 +106,14 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
<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}
|
||||
disabled={quick.isPending}
|
||||
readOnly={quick.isPending}
|
||||
aria-busy={quick.isPending}
|
||||
aria-describedby={quickError ? "quick-error" : undefined}
|
||||
/>
|
||||
</form>
|
||||
@@ -157,7 +167,7 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
</ul>
|
||||
) : entries.error ? (
|
||||
<div className="list-note" role="alert">
|
||||
<p>{entries.error.message}</p>
|
||||
<p>{friendlyError(entries.error)}</p>
|
||||
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
@@ -208,7 +218,7 @@ export function rowMeta(e: EntryView): string {
|
||||
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.work.mediaType} draft={e.draft} />
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ApiError, api } from "../api.ts";
|
||||
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();
|
||||
@@ -19,11 +21,14 @@ export function Login() {
|
||||
qc.setQueryData(["me"], user);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Sign-in failed. Try again.");
|
||||
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">
|
||||
@@ -43,7 +48,7 @@ export function Login() {
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||
</form>
|
||||
<p className="alt">
|
||||
First time here? <Link className="text-link" href="/register">Create the admin account</Link>
|
||||
Got an invite? <Link className="text-link" href="/register">Create your account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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. The API
|
||||
* cannot say whether users exist, so the screen assumes first run and switches to invite mode when
|
||||
* the server answers `invite_required` (or the link carries `?invite=`).
|
||||
* 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 [needInvite, setNeedInvite] = useState(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);
|
||||
|
||||
@@ -31,13 +33,15 @@ export function Register() {
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === "invite_required") {
|
||||
setNeedInvite(true);
|
||||
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(err instanceof ApiError ? err.message : "Couldn't create the account. Try again.");
|
||||
} 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">
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api.ts";
|
||||
import type { RatingScale } from "./format.ts";
|
||||
|
||||
/**
|
||||
* The rating scale preference. The API has no preferences endpoint yet, so it lives in this browser
|
||||
* (localStorage) and defaults to five stars with halves. Settings (Task 18) writes it with `setRatingScale`.
|
||||
*/
|
||||
const KEY = "sttracker.ratingScale";
|
||||
const SCALES: RatingScale[] = ["5half", "5", "10", "100"];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): RatingScale {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return SCALES.includes(v as RatingScale) ? (v as RatingScale) : "5half";
|
||||
} catch {
|
||||
return "5half";
|
||||
}
|
||||
}
|
||||
|
||||
export function setRatingScale(scale: RatingScale): void {
|
||||
localStorage.setItem(KEY, scale);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
const onStorage = (e: StorageEvent) => e.key === KEY && cb();
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
/** 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 useSyncExternalStore(subscribe, read, () => "5half");
|
||||
return usePreferences().data?.ratingScale ?? "5half";
|
||||
}
|
||||
|
||||
+18
-1
@@ -8,6 +8,22 @@
|
||||
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;
|
||||
@@ -62,7 +78,7 @@ body {
|
||||
line-height: 1.45;
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
caret-color: var(--rating);
|
||||
caret-color: var(--text);
|
||||
}
|
||||
::selection { background: #6d5dfc80; color: #fff; }
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent; }
|
||||
@@ -274,6 +290,7 @@ svg.icon { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: no
|
||||
.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; }
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatDateRange, formatPartialDate, formatRating, parsePartialDate, rollupLabel } from "../src/format.ts";
|
||||
import { formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
@@ -66,3 +66,31 @@ describe("rollupLabel", () => {
|
||||
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("Rating is out of range. Ratings go from 0 to 100.");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user