521 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|