171 lines
7.3 KiB
TypeScript
171 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|