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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d204775235
commit
5fa59310f3
24 files changed
+506
-136
No files matched your search
@@ -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.
|
- 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.
|
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
|
||||||
- Orange means "a platform changed something". Never use it decoratively.
|
- 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.
|
- 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)
|
## 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.
|
* `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`.
|
* 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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -138,6 +138,11 @@ export interface Attachment {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Preferences {
|
||||||
|
ratingScale: RatingScale;
|
||||||
|
defaultVisibility: "private" | "friends" | "public";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
id: string;
|
id: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -206,7 +211,10 @@ export interface EntryQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
authStatus: () => request<{ hasUsers: boolean }>("GET", "auth/status"),
|
||||||
me: () => request<User>("GET", "me"),
|
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 } }),
|
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
|
||||||
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
|
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
|
||||||
request<User>("POST", "auth/register", { body }),
|
request<User>("POST", "auth/register", { body }),
|
||||||
@@ -228,6 +236,8 @@ export const api = {
|
|||||||
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
||||||
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
|
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) => {
|
upload: (file: File, entityId: string) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("file", file);
|
form.set("file", file);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { 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 {
|
import {
|
||||||
actorConnectionId,
|
actorConnectionId,
|
||||||
describeRating,
|
describeRating,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
formatDateRange,
|
formatDateRange,
|
||||||
formatPartialDate,
|
formatPartialDate,
|
||||||
formatRating,
|
formatRating,
|
||||||
|
friendlyError,
|
||||||
ordinal,
|
ordinal,
|
||||||
parsePartialDate,
|
parsePartialDate,
|
||||||
rollupLabel,
|
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
|
// Reads / plays
|
||||||
@@ -63,9 +64,14 @@ export function EngagementsBox({
|
|||||||
setAdding(false);
|
setAdding(false);
|
||||||
refresh();
|
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>) {
|
function submit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -84,7 +90,7 @@ export function EngagementsBox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Oldest is the 1st; show newest first, like the mockup.
|
// 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();
|
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,6 +126,7 @@ export function EngagementsBox({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{removeError && <p className="error-text" role="alert">{removeError}</p>}
|
||||||
{adding && (
|
{adding && (
|
||||||
<form onSubmit={submit} aria-label={nouns.log}>
|
<form onSubmit={submit} aria-label={nouns.log}>
|
||||||
<div className="two">
|
<div className="two">
|
||||||
@@ -170,7 +177,7 @@ export function CopyBox({ entry }: { entry: EntryView }) {
|
|||||||
setAdding(false);
|
setAdding(false);
|
||||||
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
|
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
|
||||||
},
|
},
|
||||||
onError: (e) => setFormError(errText(e)),
|
onError: (e) => setFormError(friendlyError(e)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(e: FormEvent<HTMLFormElement>) {
|
function submit(e: FormEvent<HTMLFormElement>) {
|
||||||
@@ -255,21 +262,18 @@ export function CopyBox({ entry }: { entry: EntryView }) {
|
|||||||
// Attachments
|
// 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 }) {
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: (file: File) => api.upload(file, entry.id),
|
mutationFn: (file: File) => api.upload(file, entry.id),
|
||||||
onSuccess: (a) => {
|
onSuccess: () => {
|
||||||
const next = [...items, a];
|
|
||||||
uploaded.set(entry.id, next);
|
|
||||||
setItems(next);
|
|
||||||
setError(null);
|
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 (
|
return (
|
||||||
<Box title="Attachments" id="box-attachments">
|
<Box title="Attachments" id="box-attachments">
|
||||||
@@ -309,7 +313,8 @@ export function AttachmentsBox({ entry }: { entry: EntryView }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error-text" role="alert">{error}</p>}
|
{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>
|
</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 defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
|
||||||
const patch = useEntryPatch(entry.id);
|
const patch = useEntryPatch(entry.id);
|
||||||
if (!defs.data?.length) return null;
|
if (!defs.data?.length) return null;
|
||||||
const save = (def: CustomFieldDef, value: unknown) => {
|
const save = (def: CustomFieldDef, value: unknown) =>
|
||||||
const next = { ...entry.customFields };
|
patch.edit((cur) => {
|
||||||
if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) delete next[def.id];
|
const next = setCustomValue(cur.customFields, def.id, value);
|
||||||
else next[def.id] = value;
|
return JSON.stringify(next) === JSON.stringify(cur.customFields) ? null : { customFields: next };
|
||||||
if (JSON.stringify(next) !== JSON.stringify(entry.customFields)) patch.mutate({ customFields: next });
|
});
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Box title="Details" id="box-custom" wide>
|
<Box title="Details" id="box-custom" wide>
|
||||||
<div className="custom">
|
<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)} />
|
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,7 +472,7 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
|
|||||||
<Link href={`/entries/${c.id}`}>
|
<Link href={`/entries/${c.id}`}>
|
||||||
<span className="t">
|
<span className="t">
|
||||||
{c.work.title}
|
{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>
|
</span>
|
||||||
{c.rating !== null ? (
|
{c.rating !== null ? (
|
||||||
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
|
<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 }) {
|
export function ReviewBox({ entry }: { entry: EntryView }) {
|
||||||
const patch = useEntryPatch(entry.id);
|
const patch = useEntryPatch(entry.id);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const save = (v: string) => {
|
const [text, setText] = useState(entry.review ?? "");
|
||||||
const next = v.trim() === "" ? null : v;
|
// The server value this text was last in sync with. When the review changes elsewhere (Restore,
|
||||||
if (next === entry.review) return;
|
// 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) });
|
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -506,15 +521,15 @@ export function ReviewBox({ entry }: { entry: EntryView }) {
|
|||||||
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
|
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="review-text"
|
id="review-text"
|
||||||
defaultValue={entry.review ?? ""}
|
value={text}
|
||||||
placeholder="What stayed with you? Markdown works here."
|
placeholder="What stayed with you? Markdown works here."
|
||||||
onChange={() => setSaved(false)}
|
onChange={(e) => (setText(e.target.value), setSaved(false))}
|
||||||
onBlur={(e) => save(e.target.value)}
|
onBlur={save}
|
||||||
onKeyDown={(e) => {
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { ApiError, api, type EntryView } from "../api.ts";
|
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 { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
|
||||||
import { useRatingScale } from "../settings.ts";
|
import { useRatingScale } from "../settings.ts";
|
||||||
import { Cover } from "./Cover.tsx";
|
import { Cover } from "./Cover.tsx";
|
||||||
@@ -23,7 +24,7 @@ export function EntryDetail({ id }: { id: string }) {
|
|||||||
<div className="detail-empty" role="alert">
|
<div className="detail-empty" role="alert">
|
||||||
<p>
|
<p>
|
||||||
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -51,8 +52,7 @@ function Detail({ entry }: { entry: EntryView }) {
|
|||||||
const meta = [mediaTypeLabel(type)];
|
const meta = [mediaTypeLabel(type)];
|
||||||
if (entry.work.year) meta.push(String(entry.work.year));
|
if (entry.work.year) meta.push(String(entry.work.year));
|
||||||
if (finished.length) {
|
if (finished.length) {
|
||||||
const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
|
meta.push(`${nouns.past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
|
||||||
meta.push(`${past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
|
|
||||||
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
|
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
|
||||||
meta.push(`last finished ${formatPartialDate(last)}`);
|
meta.push(`last finished ${formatPartialDate(last)}`);
|
||||||
}
|
}
|
||||||
@@ -91,8 +91,12 @@ function Detail({ entry }: { entry: EntryView }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tags entry={entry} onChange={(tags) => patch.mutate({ tags })} />
|
<Tags
|
||||||
{patch.error && <p className="error-text" role="alert">{patch.error.message}</p>}
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -141,26 +145,18 @@ function whoChanged(pending: { connectionId: string | null }[], sources: { id: s
|
|||||||
return ids.size > 1 ? "Your sources" : "A source";
|
return ids.size > 1 ? "Your sources" : "A source";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
|
||||||
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 }) {
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
function add() {
|
function add() {
|
||||||
const t = draft.trim();
|
if (draft.trim()) onAdd(draft);
|
||||||
if (!t) return;
|
|
||||||
if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
||||||
{entry.tags.map((t) => (
|
{tags.map((t) => (
|
||||||
<li key={t} className="pill">
|
<li key={t} className="pill">
|
||||||
{t}
|
{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" />
|
<Icon name="x" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { api, type EntryView, type HistoryRecord } from "../api.ts";
|
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 { useSources } from "../hooks.ts";
|
||||||
import { useRatingScale } from "../settings.ts";
|
import { useRatingScale } from "../settings.ts";
|
||||||
import { Icon } from "./Icon.tsx";
|
import { Icon } from "./Icon.tsx";
|
||||||
@@ -52,7 +52,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
|||||||
const show = (field: string, v: unknown): string => {
|
const show = (field: string, v: unknown): string => {
|
||||||
if (v === null || v === undefined || v === "") return "empty";
|
if (v === null || v === undefined || v === "") return "empty";
|
||||||
if (field === "rating") return formatRating(v as number, scale);
|
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 (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" && "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 === "object") return Object.keys(v as object).length ? "edited" : "empty";
|
||||||
@@ -74,7 +74,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
{history.isPending ? null : history.error ? (
|
{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>
|
<ol>
|
||||||
{history.data.map((h) => {
|
{history.data.map((h) => {
|
||||||
@@ -109,7 +109,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
|
|||||||
})}
|
})}
|
||||||
</ol>
|
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { api, type EntryView, type Work } from "../api.ts";
|
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 { useDebounced } from "../hooks.ts";
|
||||||
import { Cover } from "./Cover.tsx";
|
import { Cover } from "./Cover.tsx";
|
||||||
import { Icon } from "./Icon.tsx";
|
import { Icon } from "./Icon.tsx";
|
||||||
@@ -96,7 +96,7 @@ export function LinkToCatalogue({ entry }: { entry: EntryView }) {
|
|||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{error && <p className="error-text" role="alert">{error.message}</p>}
|
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { api } from "../api.ts";
|
import { api } from "../api.ts";
|
||||||
import { useMe, usePendingChanges, useSources } from "../hooks.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 }) {
|
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -25,10 +26,12 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
async function signOut() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
} finally {
|
} catch {
|
||||||
qc.clear();
|
// The session may already be gone; signing out locally is still right.
|
||||||
navigate("/login", { replace: true });
|
|
||||||
}
|
}
|
||||||
|
qc.clear();
|
||||||
|
resetLibraryState();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,8 +46,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
<NavLink href="/changes" active={at("/changes")}>
|
<NavLink href="/changes" active={at("/changes")}>
|
||||||
Changes
|
Changes
|
||||||
{pending > 0 && (
|
{pending > 0 && (
|
||||||
<span className="count" aria-label={`${pending} waiting`}>
|
<span className="count">
|
||||||
{changes.data?.nextCursor ? `${pending}+` : pending}
|
{changes.data?.nextCursor ? `${pending}+` : pending}
|
||||||
|
<span className="sr-only"> waiting</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -54,7 +58,7 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
{(sources.data ?? []).map((s) => (
|
{(sources.data ?? []).map((s) => (
|
||||||
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
|
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
|
||||||
<span className="name">{s.name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
<NavLink href="/sources" active={path === "/sources"}>
|
<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. */
|
/** How ratings are shown and entered. Stored ratings are always integers 0–100. */
|
||||||
export type RatingScale = "5half" | "5" | "10" | "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. */
|
/** Size of one step of the scale, in 0–100 units. */
|
||||||
export function ratingStep(scale: RatingScale): number {
|
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. */
|
/** 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 } {
|
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", log: "Log a read" };
|
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", log: "Log a play" };
|
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", past: "Played", log: "Log a play" };
|
||||||
if (["movie", "show", "season", "episode", "video"].includes(type))
|
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))
|
if (["podcast", "podcast_episode", "audio_drama", "artist", "album", "track"].includes(type))
|
||||||
return { box: "Listens", one: "listen", verb: "Listening", log: "Log a listen" };
|
return { box: "Listens", one: "listen", verb: "Listening", past: "Listened", log: "Log a listen" };
|
||||||
return { box: "Times through", one: "time", verb: "In progress", log: "Log a time" };
|
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". */
|
/** 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()}`;
|
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 {
|
export function actorConnectionId(actor: string): string | null {
|
||||||
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
|
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
|
||||||
return m ? m[1]! : null;
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
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). */
|
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
|
||||||
export function usePendingChanges() {
|
export function usePendingChanges() {
|
||||||
@@ -31,16 +32,43 @@ export function useDebounced<T>(value: T, ms = 220): T {
|
|||||||
return v;
|
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) {
|
export function useEntryPatch(id: string) {
|
||||||
const qc = useQueryClient();
|
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),
|
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) => {
|
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: ["entries"] });
|
||||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
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 { createRoot } from "react-dom/client";
|
||||||
import { Redirect, Route, Switch } from "wouter";
|
import { Redirect, Route, Switch } from "wouter";
|
||||||
import { ApiError } from "./api.ts";
|
import { ApiError } from "./api.ts";
|
||||||
|
import { friendlyError } from "./format.ts";
|
||||||
import { Shell } from "./components/Shell.tsx";
|
import { Shell } from "./components/Shell.tsx";
|
||||||
import { useMe } from "./hooks.ts";
|
import { useMe } from "./hooks.ts";
|
||||||
import { Library } from "./routes/library.tsx";
|
import { Library } from "./routes/library.tsx";
|
||||||
@@ -35,7 +36,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
<div className="auth-card" role="alert">
|
<div className="auth-card" role="alert">
|
||||||
<span className="logo">STTracker</span>
|
<span className="logo">STTracker</span>
|
||||||
<h1>Can't load your library</h1>
|
<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>
|
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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 { 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 { Cover } from "../components/Cover.tsx";
|
||||||
import { EntryDetail } from "../components/EntryDetail.tsx";
|
import { EntryDetail } from "../components/EntryDetail.tsx";
|
||||||
import { Icon } from "../components/Icon.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 { useDebounced, usePendingChanges } from "../hooks.ts";
|
||||||
import { useRatingScale } from "../settings.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).
|
// 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 }) {
|
function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||||
const [, navigate] = useLocation();
|
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 flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
|
||||||
|
|
||||||
const [quickError, setQuickError] = useState<string | null>(null);
|
const [quickError, setQuickError] = useState<string | null>(null);
|
||||||
|
const quickInput = useRef<HTMLInputElement>(null);
|
||||||
const quick = useMutation({
|
const quick = useMutation({
|
||||||
mutationFn: api.quickAdd,
|
mutationFn: api.quickAdd,
|
||||||
onSuccess: (entry, _t) => {
|
onSuccess: (entry, _t) => {
|
||||||
qc.setQueryData(["entry", entry.id], entry);
|
qc.setQueryData(["entry", entry.id], entry);
|
||||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||||
navigate(`/entries/${entry.id}`);
|
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>) {
|
function onQuick(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
|
const title = (quickInput.current?.value ?? "").trim();
|
||||||
const title = input.value.trim();
|
|
||||||
if (!title || quick.isPending) return;
|
if (!title || quick.isPending) return;
|
||||||
setQuickError(null);
|
setQuickError(null);
|
||||||
quick.mutate(title, { onSuccess: () => (input.value = "") });
|
quick.mutate(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
|
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>
|
<label className="sr-only" htmlFor="quick-add">Quick add</label>
|
||||||
<input
|
<input
|
||||||
id="quick-add"
|
id="quick-add"
|
||||||
|
ref={quickInput}
|
||||||
name="title"
|
name="title"
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="Quick add: type a title, sort it out later"
|
placeholder="Quick add: type a title, sort it out later"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
disabled={quick.isPending}
|
readOnly={quick.isPending}
|
||||||
|
aria-busy={quick.isPending}
|
||||||
aria-describedby={quickError ? "quick-error" : undefined}
|
aria-describedby={quickError ? "quick-error" : undefined}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
@@ -157,7 +167,7 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
|||||||
</ul>
|
</ul>
|
||||||
) : entries.error ? (
|
) : entries.error ? (
|
||||||
<div className="list-note" role="alert">
|
<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>
|
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : 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> }) {
|
function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType<typeof useRatingScale> }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
|
<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 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<span className="title">{e.work.title}</span>
|
<span className="title">{e.work.title}</span>
|
||||||
<div className="meta">{rowMeta(e)}</div>
|
<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 { useState, type FormEvent } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, Redirect, useLocation } from "wouter";
|
||||||
import { ApiError, api } from "../api.ts";
|
import { api } from "../api.ts";
|
||||||
|
import { friendlyError } from "../format.ts";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
|
||||||
|
|
||||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -19,11 +21,14 @@ export function Login() {
|
|||||||
qc.setQueryData(["me"], user);
|
qc.setQueryData(["me"], user);
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "Sign-in failed. Try again.");
|
setError(friendlyError(err));
|
||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<main className="auth">
|
<main className="auth">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
@@ -43,7 +48,7 @@ export function Login() {
|
|||||||
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
|
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="alt">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 { useState, type FormEvent } from "react";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
import { ApiError, api } from "../api.ts";
|
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
|
* First-run screen. The first account becomes the admin; later accounts need an invite code.
|
||||||
* cannot say whether users exist, so the screen assumes first run and switches to invite mode when
|
* `GET auth/status` says which; `?invite=` prefills the code from an invite link.
|
||||||
* the server answers `invite_required` (or the link carries `?invite=`).
|
|
||||||
*/
|
*/
|
||||||
export function Register() {
|
export function Register() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const invite = new URLSearchParams(useSearch()).get("invite") ?? "";
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -31,13 +33,15 @@ export function Register() {
|
|||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === "invite_required") {
|
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.");
|
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);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.isPending && !invite) return <div className="boot"><span className="logo">STTracker</span></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth">
|
<main className="auth">
|
||||||
<div className="auth-card">
|
<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";
|
import type { RatingScale } from "./format.ts";
|
||||||
|
|
||||||
/**
|
/** The signed-in user's preferences (GET me/preferences). Settings updates them with `api.updatePreferences`. */
|
||||||
* The rating scale preference. The API has no preferences endpoint yet, so it lives in this browser
|
export function usePreferences() {
|
||||||
* (localStorage) and defaults to five stars with halves. Settings (Task 18) writes it with `setRatingScale`.
|
return useQuery({ queryKey: ["preferences"], queryFn: api.preferences, staleTime: 5 * 60_000 });
|
||||||
*/
|
|
||||||
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 rating scale preference; five stars with halves until preferences load. */
|
||||||
export function useRatingScale(): RatingScale {
|
export function useRatingScale(): RatingScale {
|
||||||
return useSyncExternalStore(subscribe, read, () => "5half");
|
return usePreferences().data?.ratingScale ?? "5half";
|
||||||
}
|
}
|
||||||
+18
-1
@@ -8,6 +8,22 @@
|
|||||||
font-display: swap;
|
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 {
|
:root {
|
||||||
/* DESIGN.md */
|
/* DESIGN.md */
|
||||||
--surface-0: #17152b;
|
--surface-0: #17152b;
|
||||||
@@ -62,7 +78,7 @@ body {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
font-feature-settings: "cv11", "ss01";
|
font-feature-settings: "cv11", "ss01";
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
caret-color: var(--rating);
|
caret-color: var(--text);
|
||||||
}
|
}
|
||||||
::selection { background: #6d5dfc80; color: #fff; }
|
::selection { background: #6d5dfc80; color: #fff; }
|
||||||
* { scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent; }
|
* { 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 > :last-child { text-align: right; }
|
||||||
.box .r .x { opacity: 0; transition: opacity 0.15s; }
|
.box .r .x { opacity: 0; transition: opacity 0.15s; }
|
||||||
.box .r:hover .x, .box .r .x:focus-visible { opacity: 1; }
|
.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 .empty-line { color: var(--text-muted); font-size: 0.84rem; }
|
||||||
.box form { display: grid; gap: 10px; margin-top: 8px; }
|
.box form { display: grid; gap: 10px; margin-top: 8px; }
|
||||||
.box form .two { display: grid; grid-template-columns: 1fr 1fr; gap: 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 { 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", () => {
|
describe("formatRating", () => {
|
||||||
test("5 stars with halves", () => {
|
test("5 stars with halves", () => {
|
||||||
@@ -66,3 +66,31 @@ describe("rollupLabel", () => {
|
|||||||
expect(rollupLabel("book", null)).toBeNull();
|
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 |
|
| 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/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 |
|
| `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`, `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/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/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 |
|
| `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 |
|
| `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.
|
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).
|
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
|
## 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
|
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
|
||||||
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
|
"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.
|
- [ ] 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).
|
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
||||||
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
|
- [ ] 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
|
## Someday
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ Small, specific, independently actionable. Remove only when actually done.
|
|||||||
file, and compare steps and token use.
|
file, and compare steps and token use.
|
||||||
|
|
||||||
## Known issues
|
## 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: 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: 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.
|
- [ ] 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