feat(web): changes inbox grouped by run, with per-entry filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4166ecb8f5
commit
67d94f5107
6 files changed
+693
-2
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useSearch } from "wouter";
|
||||
import { api, type ChangeEvent, type Connection, type Page } from "../api.ts";
|
||||
import { LoadError } from "../components/LoadError.tsx";
|
||||
import { changesFor, usePendingChanges, useSources } from "../hooks.ts";
|
||||
import { friendlyError, timeAgo } from "../format.ts";
|
||||
import { changeValue, describeChange, groupByRun, type ChangeAction } from "../labels.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
changed: "Changed",
|
||||
conflict: "Sources disagree",
|
||||
removed: "Gone from source",
|
||||
merged: "Merged at source",
|
||||
added: "Added",
|
||||
};
|
||||
|
||||
const DONE_LABEL: Record<ChangeAction, string> = {
|
||||
accept: "Done. The new value is in.",
|
||||
keep_mine: "Kept yours. That source won't change it again.",
|
||||
ignore: "Ignored. Nothing changed.",
|
||||
};
|
||||
|
||||
/** The Changes inbox: pending source changes, grouped by sync run. `?entry=<id>` narrows it to one entry. */
|
||||
export function Changes() {
|
||||
const search = new URLSearchParams(useSearch());
|
||||
const entryId = search.get("entry");
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const engagements = useQuery({ queryKey: ["engagements", entryId], queryFn: () => api.engagements(entryId!), enabled: !!entryId });
|
||||
const entry = useQuery({ queryKey: ["entry", entryId], queryFn: () => api.entry(entryId!), enabled: !!entryId });
|
||||
const [said, setSaid] = useState("");
|
||||
|
||||
const all = changes.data?.items ?? [];
|
||||
const items = entryId ? changesFor(all, entryId, new Set((engagements.data ?? []).map((g) => g.id))) : all;
|
||||
const groups = groupByRun(items);
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen-inner">
|
||||
<header className="screen-head">
|
||||
<h1>Changes</h1>
|
||||
<p className="lede">When a source changes something you've edited, it waits here. Nothing changes until you choose.</p>
|
||||
</header>
|
||||
{entryId && (
|
||||
<p className="filter-note">
|
||||
Showing changes to <b>{entry.data?.work.title ?? "one entry"}</b>. <Link href="/changes" className="text-link">Show all changes</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className="sr-only" role="status" aria-live="polite">{said}</p>
|
||||
{changes.error && changes.data && <LoadError error={changes.error} retry={() => void changes.refetch()} stale />}
|
||||
|
||||
{changes.isPending ? (
|
||||
<div className="change-skeleton" aria-hidden="true">
|
||||
<div className="change skeleton" />
|
||||
<div className="change skeleton" />
|
||||
</div>
|
||||
) : changes.error && !changes.data ? (
|
||||
<LoadError error={changes.error} retry={() => void changes.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>{entryId ? "Nothing waiting for this entry" : "All caught up"}</strong>
|
||||
<p>
|
||||
{entryId
|
||||
? "Its sources agree with what you have."
|
||||
: "Your sources agree with your library. When one changes something you edited, you'll decide here."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<RunGroup key={g.runId ?? "none"} events={g.events} sources={sources.data ?? []} onResolved={setSaid} />
|
||||
))
|
||||
)}
|
||||
{changes.data?.nextCursor && items.length > 0 && (
|
||||
<p className="hint more-note">Showing the newest 200. Resolve some to see older ones.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunGroup({ events, sources, onResolved }: { events: ChangeEvent[]; sources: Connection[]; onResolved: (s: string) => void }) {
|
||||
const first = events[0]!;
|
||||
const name = sources.find((s) => s.id === first.connectionId)?.name ?? "A source";
|
||||
return (
|
||||
<section className="run-group" aria-label={`${name}, ${timeAgo(first.createdAt)}`}>
|
||||
<h2 className="run-head">
|
||||
<span className="who">{name}</span>
|
||||
<span className="sep" aria-hidden="true">·</span>
|
||||
<span>sync {timeAgo(first.createdAt)}</span>
|
||||
<span className="sep" aria-hidden="true">·</span>
|
||||
<span className="tabular">{events.length} waiting</span>
|
||||
</h2>
|
||||
<ol className="changes">
|
||||
{events.map((ev) => (
|
||||
<ChangeCard key={ev.id} ev={ev} sources={sources} onResolved={onResolved} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeCard({ ev, sources, onResolved }: { ev: ChangeEvent; sources: Connection[]; onResolved: (s: string) => void }) {
|
||||
const qc = useQueryClient();
|
||||
const scale = useRatingScale();
|
||||
const d = describeChange(ev, sources);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const key = ["changes", "pending"];
|
||||
const resolve = useMutation({
|
||||
mutationFn: (action: ChangeAction) => api.resolveChange(ev.id, action),
|
||||
onMutate: async () => {
|
||||
setError(null);
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<Page<ChangeEvent>>(key);
|
||||
if (prev) qc.setQueryData<Page<ChangeEvent>>(key, { ...prev, items: prev.items.filter((c) => c.id !== ev.id) });
|
||||
return { prev };
|
||||
},
|
||||
onError: (e, _a, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
setError(friendlyError(e));
|
||||
},
|
||||
onSuccess: (_v, action) => onResolved(DONE_LABEL[action]),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
if (ev.targetEntity === "entry" && ev.targetId) {
|
||||
void qc.invalidateQueries({ queryKey: ["entry", ev.targetId] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", ev.targetId] });
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ["engagements"] });
|
||||
},
|
||||
});
|
||||
const title = ev.title ?? (ev.targetEntity === "list" ? "A list" : "An entry that's gone");
|
||||
const where = ev.targetEntity === "engagement" ? "A read or play of " : ev.targetEntity === "list" ? "List " : "";
|
||||
|
||||
return (
|
||||
<li className={`change type-${ev.type}`}>
|
||||
<div className="change-top">
|
||||
<span className="kind">{TYPE_LABEL[ev.type] ?? ev.type}</span>
|
||||
<span className="change-title">
|
||||
{where}
|
||||
{ev.targetEntity === "entry" && ev.targetId && ev.title ? <Link href={`/entries/${ev.targetId}`}>{title}</Link> : <b>{title}</b>}
|
||||
</span>
|
||||
<time className="when" dateTime={ev.createdAt}>{timeAgo(ev.createdAt)}</time>
|
||||
</div>
|
||||
<p className="change-msg">{d.message}</p>
|
||||
{d.before && d.after && (
|
||||
<dl className="diff">
|
||||
<div className="was">
|
||||
<dt>{d.before.label}</dt>
|
||||
<dd className={d.field === "rating" && d.before.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.before.value, scale)}</dd>
|
||||
</div>
|
||||
<div className="now">
|
||||
<dt>{d.after.label}</dt>
|
||||
<dd className={d.field === "rating" && d.after.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.after.value, scale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
<div className="change-actions">
|
||||
{d.actions.map((a) => (
|
||||
<button key={a.action} type="button" className={`btn small${a.action === "accept" ? " primary-soft" : ""}`} disabled={resolve.isPending} onClick={() => resolve.mutate(a.action)}>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user