feat(web): app shell, library, entry detail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
70bc6c36d3
commit
439d138967
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { mediaColour } from "../format.ts";
|
||||
|
||||
/** A cover image, the media-type gradient when there is none, or the striped draft placeholder. */
|
||||
export function Cover({ url, mediaType, draft, size }: { url: string; mediaType: string; draft: boolean; size?: "large" | "small" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const cls = `cover${size ? ` ${size}` : ""}`;
|
||||
if (url && !failed) {
|
||||
return (
|
||||
<div className={cls}>
|
||||
<img src={url} alt="" loading="lazy" onError={() => setFailed(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (draft) return <div className={`${cls} is-draft`} aria-hidden="true" />;
|
||||
const c = mediaColour(mediaType);
|
||||
return <div className={cls} aria-hidden="true" style={{ background: `linear-gradient(160deg, ${c}, color-mix(in srgb, ${c} 25%, #0b0716))` }} />;
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ApiError, api, type EntryView } from "../api.ts";
|
||||
import { engagementNouns, formatPartialDate, mediaTypeLabel, STATUSES, statusLabel } from "../format.ts";
|
||||
import { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
import { Cover } from "./Cover.tsx";
|
||||
import { AttachmentsBox, ChildrenBox, CopyBox, CustomFieldsBox, EngagementsBox, ReviewBox, SourcesBox } from "./DetailBoxes.tsx";
|
||||
import { HistoryDrawer } from "./HistoryDrawer.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { LinkToCatalogue } from "./LinkToCatalogue.tsx";
|
||||
import { StarRating } from "./StarRating.tsx";
|
||||
|
||||
const TIMES = ["", "once", "twice", "three times"];
|
||||
|
||||
export function EntryDetail({ id }: { id: string }) {
|
||||
const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
|
||||
if (entry.isPending) return <div className="detail" aria-busy="true" />;
|
||||
if (entry.error) {
|
||||
const gone = entry.error instanceof ApiError && entry.error.status === 404;
|
||||
return (
|
||||
<div className="detail-empty" role="alert">
|
||||
<p>
|
||||
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
|
||||
{gone ? "It may have been deleted or moved. Pick another from your library." : entry.error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Detail entry={entry.data} />;
|
||||
}
|
||||
|
||||
function Detail({ entry }: { entry: EntryView }) {
|
||||
const scale = useRatingScale();
|
||||
const patch = useEntryPatch(entry.id);
|
||||
const [, navigate] = useLocation();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const historyBtn = useRef<HTMLButtonElement>(null);
|
||||
const type = entry.mediaTypeOverride ?? entry.work.mediaType;
|
||||
const nouns = engagementNouns(type);
|
||||
|
||||
const engagements = useQuery({ queryKey: ["engagements", entry.id], queryFn: () => api.engagements(entry.id) });
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const engagementIds = new Set((engagements.data ?? []).map((g) => g.id));
|
||||
const pending = changesFor(changes.data?.items, entry.id, engagementIds);
|
||||
|
||||
// "Book · Read twice · last finished 14 Sep 2026"
|
||||
const finished = (engagements.data ?? []).filter((g) => g.finishedAt && g.finishedAt.precision !== "unknown");
|
||||
const meta = [mediaTypeLabel(type)];
|
||||
if (entry.work.year) meta.push(String(entry.work.year));
|
||||
if (finished.length) {
|
||||
const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
|
||||
meta.push(`${past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
|
||||
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
|
||||
meta.push(`last finished ${formatPartialDate(last)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="detail" aria-labelledby="entry-title">
|
||||
<Link href="/" className="btn ghost back"><Icon name="back" /> Library</Link>
|
||||
<header className="det-head">
|
||||
<Cover url={entry.work.coverUrl} mediaType={type} draft={entry.draft} size="large" />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 id="entry-title">
|
||||
{entry.work.title}
|
||||
{entry.draft && <span className="draft">Draft</span>}
|
||||
</h1>
|
||||
<p className="m">{meta.join(" · ")}</p>
|
||||
<div className="det-controls">
|
||||
<StarRating value={entry.rating} scale={scale} label="Your rating" onChange={(rating) => patch.mutate({ rating })} />
|
||||
<label>
|
||||
<span className="sr-only">Status</span>
|
||||
<select className="select" value={entry.status} onChange={(e) => patch.mutate({ status: e.target.value as EntryView["status"] })}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{statusLabel(s, type)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="det-tools">
|
||||
<button
|
||||
ref={historyBtn}
|
||||
type="button"
|
||||
className="btn ghost small"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls="history-drawer"
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="history" /> History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tags entry={entry} onChange={(tags) => patch.mutate({ tags })} />
|
||||
{patch.error && <p className="error-text" role="alert">{patch.error.message}</p>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="alert" role="status">
|
||||
<p>
|
||||
<b>{whoChanged(pending, sources.data)} changed this {mediaTypeLabel(type).toLowerCase()}.</b>{" "}
|
||||
{pending.length === 1 ? `On ${shortDate(pending[0]!.createdAt)}.` : `${pending.length} changes are waiting for you.`} Your rating,{" "}
|
||||
{nouns.box.toLowerCase()} and review here are unchanged.
|
||||
</p>
|
||||
<button type="button" className="btn" onClick={() => navigate(`/changes?entry=${entry.id}`)}>See what changed</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.draft && <LinkToCatalogue entry={entry} />}
|
||||
|
||||
<div className="det-grid">
|
||||
<EngagementsBox entry={entry} nouns={nouns} engagements={engagements.data} loading={engagements.isPending} />
|
||||
<CopyBox entry={entry} />
|
||||
<AttachmentsBox entry={entry} />
|
||||
<SourcesBox entry={entry} engagements={engagements.data ?? []} />
|
||||
<CustomFieldsBox entry={entry} type={type} />
|
||||
{entry.childSummary && <ChildrenBox entry={entry} type={type} />}
|
||||
<ReviewBox entry={entry} />
|
||||
</div>
|
||||
|
||||
{historyOpen && (
|
||||
<HistoryDrawer
|
||||
entry={entry}
|
||||
onClose={() => {
|
||||
setHistoryOpen(false);
|
||||
historyBtn.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function whoChanged(pending: { connectionId: string | null }[], sources: { id: string; name: string }[] | undefined): string {
|
||||
const ids = new Set(pending.map((p) => p.connectionId));
|
||||
if (ids.size === 1) {
|
||||
const name = sources?.find((s) => s.id === [...ids][0])?.name;
|
||||
if (name) return name;
|
||||
}
|
||||
return ids.size > 1 ? "Your sources" : "A source";
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
function shortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
|
||||
}
|
||||
|
||||
function Tags({ entry, onChange }: { entry: EntryView; onChange: (tags: string[]) => void }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
function add() {
|
||||
const t = draft.trim();
|
||||
if (!t) return;
|
||||
if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
|
||||
setDraft("");
|
||||
}
|
||||
return (
|
||||
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
||||
{entry.tags.map((t) => (
|
||||
<li key={t} className="pill">
|
||||
{t}
|
||||
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onChange(entry.tags.filter((x) => x !== t))}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<label>
|
||||
<span className="sr-only">Add a tag</span>
|
||||
<input
|
||||
className="tag-input"
|
||||
placeholder="+ tag"
|
||||
value={draft}
|
||||
maxLength={100}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
onBlur={add}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api, type EntryView, type HistoryRecord } from "../api.ts";
|
||||
import { actorConnectionId, formatPartialDate, formatRating, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
|
||||
import { useSources } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
status: "Status",
|
||||
rating: "Rating",
|
||||
review: "Review",
|
||||
privateNotes: "Private notes",
|
||||
favorite: "Favourite",
|
||||
tags: "Tags",
|
||||
customFields: "Details",
|
||||
visibility: "Visibility",
|
||||
overrides: "Title and cover",
|
||||
mediaTypeOverride: "Media type",
|
||||
workId: "Catalogue link",
|
||||
deletedAt: "Removed",
|
||||
};
|
||||
const SKIP = new Set(["id", "userId", "fieldSources", "createdAt", "updatedAt"]);
|
||||
|
||||
/** History of an entry, newest first, with "Restore" to put back what a change replaced. */
|
||||
export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const scale = useRatingScale();
|
||||
const sources = useSources();
|
||||
const closeBtn = useRef<HTMLButtonElement>(null);
|
||||
const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
|
||||
const restore = useMutation({
|
||||
mutationFn: api.restore,
|
||||
onSuccess: () => {
|
||||
for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
closeBtn.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const who = (actor: string) => {
|
||||
if (actor === "user") return "You";
|
||||
if (actor.startsWith("token:")) return `App token “${actor.slice(6)}”`;
|
||||
const id = actorConnectionId(actor);
|
||||
return sources.data?.find((s) => s.id === id)?.name ?? actor.split(":")[1] ?? "A source";
|
||||
};
|
||||
const show = (field: string, v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "empty";
|
||||
if (field === "rating") return formatRating(v as number, scale);
|
||||
if (field === "status") return statusLabel(v as Status, entry.work.mediaType);
|
||||
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
|
||||
if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
|
||||
if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
|
||||
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||
const s = String(v);
|
||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||
};
|
||||
const diffs = (h: HistoryRecord) =>
|
||||
[...new Set([...Object.keys(h.before ?? {}), ...Object.keys(h.after ?? {})])]
|
||||
.filter((k) => !SKIP.has(k) && JSON.stringify(h.before?.[k]) !== JSON.stringify(h.after?.[k]))
|
||||
.map((k) => ({ k, before: show(k, h.before?.[k]), after: show(k, h.after?.[k]) }));
|
||||
|
||||
return (
|
||||
<aside className="drawer" id="history-drawer" role="dialog" aria-modal="false" aria-labelledby="history-title">
|
||||
<header>
|
||||
<h2 id="history-title">History</h2>
|
||||
<button ref={closeBtn} type="button" className="btn ghost small" onClick={onClose} aria-label="Close history">
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</header>
|
||||
{history.isPending ? null : history.error ? (
|
||||
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{history.error.message}</p>
|
||||
) : (
|
||||
<ol>
|
||||
{history.data.map((h) => {
|
||||
const d = diffs(h);
|
||||
const created = Object.keys(h.before ?? {}).length === 0;
|
||||
return (
|
||||
<li key={h.id}>
|
||||
<div className="when">
|
||||
<span><span className="who">{who(h.actor)}</span> · {timeAgo(h.createdAt)}</span>
|
||||
{!created && d.length > 0 && (
|
||||
<button type="button" className="btn small" disabled={restore.isPending} onClick={() => restore.mutate(h.id)}>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{created ? (
|
||||
<p className="hint">Added to your library.</p>
|
||||
) : d.length === 0 ? (
|
||||
<p className="hint">No visible change.</p>
|
||||
) : (
|
||||
<dl>
|
||||
{d.map(({ k, before, after }) => (
|
||||
<div key={k} style={{ display: "contents" }}>
|
||||
<dt>{LABELS[k] ?? k}</dt>
|
||||
<dd><s>{before}</s><span className="arrow" aria-label="changed to">→</span>{after}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{restore.error.message}</p>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** A small authored icon set: 24px grid, 1.75 stroke, round caps. */
|
||||
const PATHS = {
|
||||
plus: "M12 5v14M5 12h14",
|
||||
search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4",
|
||||
x: "M6 6l12 12M18 6L6 18",
|
||||
history: "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2",
|
||||
back: "M15 18l-6-6 6-6",
|
||||
upload: "M12 16V4M7 9l5-5 5 5M5 20h14",
|
||||
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
|
||||
file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function Icon({ name }: { name: IconName }) {
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { api, type EntryView, type Work } from "../api.ts";
|
||||
import { mediaTypeLabel } from "../format.ts";
|
||||
import { useDebounced } from "../hooks.ts";
|
||||
import { Cover } from "./Cover.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
|
||||
/** Drafts only: suggested matches and a catalogue search. Linking keeps the user's rating, reads and review. */
|
||||
export function LinkToCatalogue({ entry }: { entry: EntryView }) {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const [q, setQ] = useState("");
|
||||
const term = useDebounced(q.trim());
|
||||
const suggestions = useQuery({ queryKey: ["suggestions", entry.workId], queryFn: () => api.suggestions(entry.workId) });
|
||||
const candidates = useQueries({
|
||||
queries: (suggestions.data ?? []).slice(0, 5).map((s) => ({ queryKey: ["work", s.candidateWorkId], queryFn: () => api.work(s.candidateWorkId), staleTime: 300_000 })),
|
||||
});
|
||||
const search = useQuery({
|
||||
queryKey: ["works-search", term],
|
||||
queryFn: () => api.searchWorks(term),
|
||||
enabled: term.length >= 2,
|
||||
});
|
||||
|
||||
const done = (newId?: string) => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["entry", entry.id] });
|
||||
void qc.invalidateQueries({ queryKey: ["suggestions", entry.workId] });
|
||||
if (newId && newId !== entry.id) navigate(`/entries/${newId}`, { replace: true });
|
||||
};
|
||||
const move = useMutation({ mutationFn: (workId: string) => api.moveEntry(entry.id, workId), onSuccess: (e) => done(e.id) });
|
||||
const accept = useMutation({ mutationFn: api.acceptSuggestion, onSuccess: () => done() });
|
||||
const reject = useMutation({ mutationFn: api.rejectSuggestion, onSuccess: () => done() });
|
||||
const busy = move.isPending || accept.isPending || reject.isPending;
|
||||
const error = move.error ?? accept.error ?? reject.error;
|
||||
|
||||
const row = (w: Work, actions: ReactNode) => (
|
||||
<li key={w.id}>
|
||||
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div>{w.title}</div>
|
||||
<div className="m">{[mediaTypeLabel(w.media_type), w.year, w.creators?.[0]?.name].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="acts">{actions}</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
const results = (search.data ?? []).filter((w) => w.id !== entry.workId);
|
||||
return (
|
||||
<section className="box link-box" aria-labelledby="link-title">
|
||||
<div className="box-head">
|
||||
<h2 id="link-title"><Icon name="link" /> Link to catalogue</h2>
|
||||
</div>
|
||||
<p>This is a draft. Link it to a catalogue entry to fill in the cover and details. Your rating, reads and review stay as they are.</p>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<h3 className="sub">Suggested</h3>
|
||||
<ul className="results">
|
||||
{suggestions.data!.slice(0, 5).map((s, i) => {
|
||||
const w = candidates[i]?.data;
|
||||
if (!w) return null;
|
||||
return row(
|
||||
w,
|
||||
<>
|
||||
<button type="button" className="btn primary small" disabled={busy} onClick={() => accept.mutate(s.id)}>Link</button>
|
||||
<button type="button" className="btn ghost small" disabled={busy} onClick={() => reject.mutate(s.id)}>Not this</button>
|
||||
</>,
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="search">
|
||||
<Icon name="search" />
|
||||
<label htmlFor="catalogue-search" className="sr-only">Search the catalogue</label>
|
||||
<input
|
||||
id="catalogue-search"
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Search the catalogue…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{term.length >= 2 && !search.isPending && (
|
||||
results.length === 0 ? (
|
||||
<p className="hint">Nothing in the catalogue matches “{term}”. Connect a source to bring in more.</p>
|
||||
) : (
|
||||
<ul className="results" aria-label="Search results">
|
||||
{results.map((w) =>
|
||||
row(w, <button type="button" className="btn primary small" disabled={busy} onClick={() => move.mutate(w.id)}>Link</button>),
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
{error && <p className="error-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { api } from "../api.ts";
|
||||
import { useMe, usePendingChanges, useSources } from "../hooks.ts";
|
||||
|
||||
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Link href={href} className={`nav${className ? ` ${className}` : ""}`} aria-current={active ? "page" : undefined}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sidebar navigation plus the main area. Stacks into a top bar on narrow screens. */
|
||||
export function Shell({ children }: { children: ReactNode }) {
|
||||
const [path, navigate] = useLocation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const pending = changes.data?.items.length ?? 0;
|
||||
const at = (p: string) => path === p || path.startsWith(`${p}/`);
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
qc.clear();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<Link href="/" className="logo">STTracker</Link>
|
||||
<nav aria-label="Main">
|
||||
<NavLink href="/" active={path === "/" || at("/entries")}>Library</NavLink>
|
||||
<NavLink href="/log" active={at("/log")}>Log</NavLink>
|
||||
<NavLink href="/lists" active={at("/lists")}>Lists</NavLink>
|
||||
<NavLink href="/owned" active={at("/owned")}>Owned</NavLink>
|
||||
<NavLink href="/changes" active={at("/changes")}>
|
||||
Changes
|
||||
{pending > 0 && (
|
||||
<span className="count" aria-label={`${pending} waiting`}>
|
||||
{changes.data?.nextCursor ? `${pending}+` : pending}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<h2 className="grp">Sources</h2>
|
||||
<nav aria-label="Sources" className="src-nav">
|
||||
{(sources.data ?? []).map((s) => (
|
||||
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
|
||||
<span className="name">{s.name}</span>
|
||||
<span className={`dot${s.status === "active" ? "" : " paused"}`} aria-label={s.status === "active" ? "Active" : "Paused"} />
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink href="/sources" active={path === "/sources"}>
|
||||
{sources.data?.length ? "All sources" : "Connect a source"}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<nav aria-label="Account" style={{ marginTop: 14 }}>
|
||||
<NavLink href="/settings" active={at("/settings")}>Settings</NavLink>
|
||||
</nav>
|
||||
<div className="side-foot">
|
||||
{me.data && <span className="who">{me.data.displayName || me.data.username}</span>}
|
||||
<button type="button" onClick={() => void signOut()}>Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { describeRating, ratingStep, snapRating, type RatingScale } from "../format.ts";
|
||||
|
||||
const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
|
||||
|
||||
/**
|
||||
* Editable rating in the user's scale. Stars are a slider: arrows step (half a star on "5half"),
|
||||
* Home/End jump, Delete clears. The 10 and 100 point scales use a number field.
|
||||
*/
|
||||
export function StarRating({
|
||||
value,
|
||||
scale,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
scale: RatingScale;
|
||||
label: string;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
// Arrow keys step a local value; it is saved once the user pauses (or leaves), not on every step.
|
||||
const [stepping, setStepping] = useState<{ v: number | null } | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clipBase = useId();
|
||||
const step = ratingStep(scale);
|
||||
// Leaving mid-step (navigating to another entry) still saves the stepped value.
|
||||
const latest = useRef({ stepping, value, onChange });
|
||||
latest.current = { stepping, value, onChange };
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
const { stepping: st, value: v, onChange: save } = latest.current;
|
||||
if (st && st.v !== v) save(st.v);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (scale === "10" || scale === "100") {
|
||||
const max = scale === "10" ? 10 : 100;
|
||||
const shown = value === null ? "" : String(scale === "10" ? snapRating(value, scale) / 10 : value);
|
||||
return (
|
||||
<label className="rating-number">
|
||||
<span className="sr-only">{label}</span>
|
||||
<input
|
||||
className="input tabular"
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
step={scale === "10" ? 0.5 : 1}
|
||||
defaultValue={shown}
|
||||
key={shown}
|
||||
onBlur={(e) => {
|
||||
const raw = e.currentTarget.value.trim();
|
||||
if (raw === "") return value !== null && onChange(null);
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return;
|
||||
const v = snapRating(scale === "10" ? n * 10 : n, scale);
|
||||
if (v !== value) onChange(v);
|
||||
}}
|
||||
/>
|
||||
<span>/ {max}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (v: number | null) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
setStepping(null);
|
||||
if (v !== value) onChange(v === null ? null : snapRating(v, scale));
|
||||
};
|
||||
const flush = () => stepping && set(stepping.v);
|
||||
const current = stepping ? stepping.v : value;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const cur = current ?? 0;
|
||||
const map: Record<string, number | null> = {
|
||||
ArrowRight: Math.min(100, cur + step),
|
||||
ArrowUp: Math.min(100, cur + step),
|
||||
ArrowLeft: Math.max(0, cur - step),
|
||||
ArrowDown: Math.max(0, cur - step),
|
||||
Home: 0,
|
||||
End: 100,
|
||||
Delete: null,
|
||||
Backspace: null,
|
||||
};
|
||||
if (!(e.key in map)) return;
|
||||
e.preventDefault();
|
||||
const next = map[e.key]!;
|
||||
setStepping({ v: next });
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => set(next), 500);
|
||||
};
|
||||
const valueAt = (e: PointerEvent<HTMLSpanElement>, i: number) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const half = scale === "5half" && e.clientX - r.left < r.width / 2;
|
||||
return (i + (half ? 0.5 : 1)) * 20;
|
||||
};
|
||||
const shown = hover ?? current ?? 0;
|
||||
|
||||
return (
|
||||
<span className="rating-wrap" style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
<span
|
||||
className="rating"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={current ?? undefined}
|
||||
aria-valuetext={describeRating(current, scale)}
|
||||
onKeyDown={onKey}
|
||||
onBlur={flush}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const fill = Math.max(0, Math.min(1, shown / 20 - i));
|
||||
const id = `${clipBase}-${i}`;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onPointerMove={(e) => setHover(valueAt(e, i))}
|
||||
onClick={(e) => {
|
||||
const v = valueAt(e as unknown as PointerEvent<HTMLSpanElement>, i);
|
||||
set(v === value ? null : v);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={id}>
|
||||
<rect x="0" y="0" width={24 * fill} height="24" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path className="empty" d={STAR} />
|
||||
{fill > 0 && <path className="full" d={STAR} clipPath={`url(#${id})`} />}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{current !== null && (
|
||||
<button type="button" className="rating-clear" onClick={() => set(null)}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user