Files
STTracker/apps/web/src/components/DetailBoxes.tsx
T
2026-10-30 21:37:56 +01:00

521 lines
20 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 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 {
actorConnectionId,
describeRating,
engagementNouns,
formatDateRange,
formatPartialDate,
formatRating,
ordinal,
parsePartialDate,
rollupLabel,
statusLabel,
timeAgo,
type PartialDate,
} from "../format.ts";
import { useEntryPatch, useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Icon } from "./Icon.tsx";
import { StarRating } from "./StarRating.tsx";
function Box({ title, id, action, wide, className, children }: { title: string; id: string; action?: ReactNode; wide?: boolean; className?: string; children: ReactNode }) {
return (
<section className={`box${wide ? " wide" : ""}${className ? ` ${className}` : ""}`} aria-labelledby={id}>
<div className="box-head">
<h2 id={id}>{title}</h2>
{action}
</div>
{children}
</section>
);
}
const errText = (e: unknown) => (e instanceof ApiError ? e.message : "That didn't save. Try again.");
// ---------------------------------------------------------------------------------------------
// Reads / plays
// ---------------------------------------------------------------------------------------------
export function EngagementsBox({
entry,
nouns,
engagements,
loading,
}: {
entry: EntryView;
nouns: ReturnType<typeof engagementNouns>;
engagements: Engagement[] | undefined;
loading: boolean;
}) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["engagements", entry.id] });
void qc.invalidateQueries({ queryKey: ["history", entry.id] });
};
const add = useMutation({
mutationFn: (b: { startedAt: PartialDate | null; finishedAt: PartialDate | null }) => api.addEngagement(entry.id, b),
onSuccess: () => {
setAdding(false);
refresh();
},
onError: (e) => setFormError(errText(e)),
});
const remove = useMutation({ mutationFn: api.deleteEngagement, onSuccess: refresh });
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const read = (k: string) => {
const raw = String(f.get(k) ?? "").trim();
if (!raw) return { ok: true as const, v: null };
const pd = parsePartialDate(raw);
return pd ? { ok: true as const, v: pd } : { ok: false as const, v: null };
};
const s = read("startedAt");
const d = read("finishedAt");
if (!s.ok || !d.ok) return setFormError("Use a year, a month or a day: 2025, 2025-03 or 2025-03-14.");
setFormError(null);
add.mutate({ startedAt: s.v, finishedAt: d.v });
}
// Oldest is the 1st; show newest first, like the mockup.
const ordered = [...(engagements ?? [])].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
return (
<Box
title={nouns.box}
id="box-engagements"
action={
!adding && (
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
<Icon name="plus" /> {nouns.log}
</button>
)
}
>
{loading ? null : rows.length === 0 && !adding ? (
<p className="empty-line">Nothing logged yet.</p>
) : (
rows.map(({ g, n }) => (
<div className="r" key={g.id}>
<span>{ordinal(n)} {nouns.one}</span>
<span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
<span className="tabular">{formatDateRange(g.startedAt, g.finishedAt)}</span>
<button
type="button"
className="btn ghost small x"
aria-label={`Remove ${ordinal(n)} ${nouns.one}`}
disabled={remove.isPending}
onClick={() => remove.mutate(g.id)}
>
<Icon name="x" />
</button>
</span>
</div>
))
)}
{adding && (
<form onSubmit={submit} aria-label={nouns.log}>
<div className="two">
<label className="field">
<span>Started</span>
<input className="input tabular" name="startedAt" placeholder="2025-03-14" inputMode="numeric" autoFocus aria-describedby="date-hint" />
</label>
<label className="field">
<span>Finished</span>
<input className="input tabular" name="finishedAt" placeholder="2025-03" inputMode="numeric" aria-describedby="date-hint" />
</label>
</div>
<small className="hint" id="date-hint">A year, month or day is fine. Leave blank if you don't know.</small>
{formError && <p className="error-text" role="alert">{formError}</p>}
<div className="actions">
<button className="btn primary small" disabled={add.isPending}>Save</button>
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
</div>
</form>
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Your copy
// ---------------------------------------------------------------------------------------------
function formatPrice(c: Copy): string {
if (c.priceCents === null) return "";
const amount = c.priceCents / 100;
if (!c.currency) return amount.toFixed(2);
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency: c.currency, minimumFractionDigits: c.priceCents % 100 ? 2 : 0 }).format(amount);
} catch {
return `${amount.toFixed(2)} ${c.currency}`;
}
}
export function CopyBox({ entry }: { entry: EntryView }) {
const qc = useQueryClient();
const copies = useQuery({ queryKey: ["copies", entry.workId], queryFn: () => api.copies(entry.workId) });
const [adding, setAdding] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const add = useMutation({
mutationFn: api.addCopy,
onSuccess: () => {
setAdding(false);
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
},
onError: (e) => setFormError(errText(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const s = (k: string) => String(f.get(k) ?? "").trim() || null;
const when = s("acquiredAt");
const acquiredAt = when ? parsePartialDate(when) : null;
if (when && !acquiredAt) return setFormError("Use a year, a month or a day for when: 2025, 2025-02 or 2025-02-14.");
const price = s("price");
const priceNum = price ? Number(price.replace(",", ".")) : null;
if (price && (priceNum === null || Number.isNaN(priceNum))) return setFormError("Price should be a number, like 14 or 14.99.");
const currency = s("currency")?.toUpperCase() ?? null;
if (currency && !/^[A-Z]{3}$/.test(currency)) return setFormError("Currency is a three-letter code, like GBP or EUR.");
setFormError(null);
add.mutate({
workId: entry.workId,
format: s("format"),
acquiredFrom: s("acquiredFrom"),
acquiredAt,
priceCents: priceNum === null ? null : Math.round(priceNum * 100),
currency,
});
}
const list = copies.data ?? [];
return (
<Box
title="Your copy"
id="box-copy"
action={
!adding && list.length > 0 && (
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
<Icon name="plus" /> Another
</button>
)
}
>
{copies.isPending ? null : list.length === 0 && !adding ? (
<div className="r">
<span>You haven't recorded a copy.</span>
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>Add your copy</button>
</div>
) : (
list.map((c) => {
const when = [formatPartialDate(c.acquiredAt), formatPrice(c)].filter(Boolean).join(" · ");
return (
<div className="copy" key={c.id}>
{c.acquiredFrom && <div className="r"><span>Bought</span><span>{c.acquiredFrom}</span></div>}
{when && <div className="r"><span>When / price</span><span className="tabular">{when}</span></div>}
{c.format && <div className="r"><span>Format</span><span>{c.format}</span></div>}
{!c.acquiredFrom && !when && !c.format && <div className="r"><span>Owned</span><span>No details yet</span></div>}
</div>
);
})
)}
{adding && (
<form onSubmit={submit} aria-label="Add your copy">
<div className="two">
<label className="field"><span>Bought at</span><input className="input" name="acquiredFrom" placeholder="Waterstones, Leeds" autoFocus /></label>
<label className="field"><span>Format</span><input className="input" name="format" placeholder="Hardback" /></label>
</div>
<div className="two">
<label className="field"><span>When</span><input className="input tabular" name="acquiredAt" placeholder="2025-02" /></label>
<div className="two" style={{ gridTemplateColumns: "1fr 72px" }}>
<label className="field"><span>Price</span><input className="input tabular" name="price" inputMode="decimal" placeholder="14" /></label>
<label className="field"><span>Currency</span><input className="input" name="currency" placeholder="GBP" maxLength={3} /></label>
</div>
</div>
{formError && <p className="error-text" role="alert">{formError}</p>}
<div className="actions">
<button className="btn primary small" disabled={add.isPending}>Save</button>
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
</div>
</form>
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------------------------
// 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 [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);
setError(null);
},
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)),
});
return (
<Box title="Attachments" id="box-attachments">
<div className="thumbs">
{items.map((a) => {
const url = `/api/v1/attachments/${a.id}`;
const image = a.mime.startsWith("image/");
return (
<a
key={a.id}
className="thumb"
href={url}
target="_blank"
rel="noreferrer"
title={a.filename}
aria-label={`Open ${a.filename}`}
style={image ? { backgroundImage: `url("${url}")` } : undefined}
>
{!image && <Icon name="file" />}
</a>
);
})}
<label className="thumb upload" title="Add screenshots, photos or receipts">
<span className="sr-only">Upload an attachment</span>
{upload.isPending ? "…" : <Icon name="upload" />}
<input
type="file"
className="sr-only"
accept="image/*,application/pdf,text/plain"
disabled={upload.isPending}
onChange={(e) => {
const f = e.currentTarget.files?.[0];
if (f) upload.mutate(f);
e.currentTarget.value = "";
}}
/>
</label>
</div>
{error && <p className="error-text" role="alert">{error}</p>}
{items.length === 0 && !error && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Linked sources
// ---------------------------------------------------------------------------------------------
export function SourcesBox({ entry, engagements }: { entry: EntryView; engagements: Engagement[] }) {
const sources = useSources();
// A source is linked when it has written any field of the entry or its reads.
const ids = new Set<string>();
for (const fs of [entry.fieldSources, ...engagements.map((g) => g.fieldSources)]) {
for (const actor of Object.values(fs ?? {})) {
const id = actorConnectionId(actor);
if (id) ids.add(id);
}
}
const linked = (sources.data ?? []).filter((s) => ids.has(s.id));
return (
<Box title="Linked sources" id="box-sources">
{linked.length === 0 ? (
<p className="empty-line">No source syncs this entry. Everything here is yours.</p>
) : (
linked.map((s) => (
<div className="src" key={s.id}>
<i className={s.status === "active" ? "" : "paused"} aria-hidden="true" />
{s.name} · {s.status !== "active" ? "paused" : s.lastRunAt ? `synced ${timeAgo(s.lastRunAt)}` : "not synced yet"}
</div>
))
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Custom fields
// ---------------------------------------------------------------------------------------------
export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
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 });
};
return (
<Box title="Details" id="box-custom" wide>
<div className="custom">
{defs.data.map((d) => (
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
))}
</div>
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
</Box>
);
}
function CustomField({ def, value, onSave }: { def: CustomFieldDef; value: unknown; onSave: (v: unknown) => void }) {
const scale = useRatingScale();
const str = value === undefined || value === null ? "" : String(value);
const text = (multiline: boolean, inputType = "text") => (
<label className="field">
<span>{def.name}</span>
{multiline ? (
<textarea className="textarea" defaultValue={str} key={str} rows={3} onBlur={(e) => onSave(e.target.value.trim())} />
) : (
<input
className="input"
type={inputType}
defaultValue={str}
key={str}
placeholder={def.type === "date" ? "2025-03-14" : undefined}
onBlur={(e) => {
const v = e.target.value.trim();
if (def.type === "number") onSave(v === "" ? null : Number(v));
else if (def.type === "date") onSave(v === "" || parsePartialDate(v) ? v : str);
else onSave(v);
}}
/>
)}
</label>
);
switch (def.type) {
case "long_text":
return text(true);
case "number":
return text(false, "number");
case "url":
return text(false, "url");
case "boolean":
return (
<label className="inline">
<input type="checkbox" checked={value === true} onChange={(e) => onSave(e.target.checked ? true : null)} />
{def.name}
</label>
);
case "single_select":
return (
<label className="field">
<span>{def.name}</span>
<select className="select" value={str} onChange={(e) => onSave(e.target.value || null)}>
<option value="">—</option>
{def.options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
);
case "multi_select": {
const cur = Array.isArray(value) ? (value as string[]) : [];
return (
<fieldset className="field" style={{ border: 0 }}>
<legend className="hint" style={{ color: "var(--text-muted)", fontWeight: 500, marginBottom: 5 }}>{def.name}</legend>
<div className="checks">
{def.options.map((o) => (
<label key={o} className="inline">
<input type="checkbox" checked={cur.includes(o)} onChange={(e) => onSave(e.target.checked ? [...cur, o] : cur.filter((x) => x !== o))} />
{o}
</label>
))}
</div>
</fieldset>
);
}
case "rating":
return (
<div className="field">
<span>{def.name}</span>
<StarRating value={typeof value === "number" ? value : null} scale={scale} label={def.name} onChange={onSave} />
</div>
);
default:
return text(false);
}
}
// ---------------------------------------------------------------------------------------------
// Children (seasons, episodes, volumes)
// ---------------------------------------------------------------------------------------------
export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {
const scale = useRatingScale();
const children = useQuery({
queryKey: ["entries", { parentId: entry.workId }],
queryFn: () => api.entries({ parentId: entry.workId, limit: 200 }),
});
const label = rollupLabel(type, entry.childSummary ? { ...entry.childSummary, completed: 0 } : null) ?? "Parts";
const title = label.replace(/^\d+\s+/, "");
return (
<Box title={title.charAt(0).toUpperCase() + title.slice(1)} id="box-children" wide>
<ul className="children">
{(children.data?.items ?? []).map((c) => (
<li key={c.id}>
<Link href={`/entries/${c.id}`}>
<span className="t">
{c.work.title}
<span className="s">{statusLabel(c.status, c.work.mediaType)}</span>
</span>
{c.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
) : (
<span className="s">Not rated</span>
)}
</Link>
</li>
))}
</ul>
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Review
// ---------------------------------------------------------------------------------------------
export function ReviewBox({ entry }: { entry: EntryView }) {
const patch = useEntryPatch(entry.id);
const [saved, setSaved] = useState(false);
const save = (v: string) => {
const next = v.trim() === "" ? null : v;
if (next === entry.review) return;
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
};
return (
<Box
title="Your review"
id="box-review"
wide
className="review"
action={<span className="saved" aria-live="polite">{patch.isPending ? "Saving…" : saved ? "Saved" : ""}</span>}
>
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
defaultValue={entry.review ?? ""}
placeholder="What stayed with you? Markdown works here."
onChange={() => setSaved(false)}
onBlur={(e) => save(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save(e.currentTarget.value);
}}
/>
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
</Box>
);
}