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 (

{title}

{action}
{children}
); } 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; engagements: Engagement[] | undefined; loading: boolean; }) { const qc = useQueryClient(); const [adding, setAdding] = useState(false); const [formError, setFormError] = useState(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) { 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 ( setAdding(true)}> {nouns.log} ) } > {loading ? null : rows.length === 0 && !adding ? (

Nothing logged yet.

) : ( rows.map(({ g, n }) => (
{ordinal(n)} {nouns.one} {formatDateRange(g.startedAt, g.finishedAt)}
)) )} {adding && (
A year, month or day is fine. Leave blank if you don't know. {formError &&

{formError}

}
)}
); } // --------------------------------------------------------------------------------------------- // 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(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) { 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 ( 0 && ( ) } > {copies.isPending ? null : list.length === 0 && !adding ? (
You haven't recorded a copy.
) : ( list.map((c) => { const when = [formatPartialDate(c.acquiredAt), formatPrice(c)].filter(Boolean).join(" · "); return (
{c.acquiredFrom &&
Bought{c.acquiredFrom}
} {when &&
When / price{when}
} {c.format &&
Format{c.format}
} {!c.acquiredFrom && !when && !c.format &&
OwnedNo details yet
}
); }) )} {adding && (
{formError &&

{formError}

}
)}
); } // --------------------------------------------------------------------------------------------- // Attachments // --------------------------------------------------------------------------------------------- // The API has no "list attachments of an entry" route yet, so this shows what was uploaded in this session. const uploaded = new Map(); export function AttachmentsBox({ entry }: { entry: EntryView }) { const [items, setItems] = useState(() => uploaded.get(entry.id) ?? []); const [error, setError] = useState(null); const upload = useMutation({ mutationFn: (file: File) => api.upload(file, entry.id), onSuccess: (a) => { const next = [...items, a]; uploaded.set(entry.id, next); setItems(next); 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 (
{items.map((a) => { const url = `/api/v1/attachments/${a.id}`; const image = a.mime.startsWith("image/"); return ( {!image && } ); })}
{error &&

{error}

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

Screenshots, photos of your copy, receipts.

}
); } // --------------------------------------------------------------------------------------------- // 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(); 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 ( {linked.length === 0 ? (

No source syncs this entry. Everything here is yours.

) : ( linked.map((s) => (