React-Frontend: Feed mit Beitragskarte, Votes und Repost

- Feed per useInfiniteQuery mit Sentinel-Nachladen, Duplikate gefiltert,
  Stopp an der Backend-Seitengrenze
- EntryCard mit unverändertem Markup (inkl. [deleted]-Platzhalter)
- Votes mit optimistischem Umschalten, Rollback bei Fehler
- Repost mit Cooldown-Anzeige und 429-Auswertung
- linkify ohne innerHTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:30:17 +02:00
co-authored by Claude Opus 5.5
parent 37279ac90d
commit bd5c59bcad
6 changed files with 291 additions and 2 deletions
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { VoteMode, VoteTally } from "../types";
// toggled rechnet vor, was der Server aus einer Stimme macht (siehe
// handleVote): gleiche Stimme zieht sie zurück, andere wechselt. Damit
// schaltet der Knopf sofort um; die Serverantwort überschreibt danach.
function toggled(t: VoteTally, mode: VoteMode): VoteTally {
const next = { ...t };
if (t.selected !== "none") next[t.selected]--;
if (t.selected === mode) {
next.selected = "none";
} else {
next[mode]++;
next.selected = mode;
}
return next;
}
export function Votes({ pid }: { pid: number }) {
const qc = useQueryClient();
const key = ["votes", pid];
const tally = useQuery({
queryKey: key,
queryFn: () => api.get<VoteTally>(`/entry/${pid}/votes`),
});
const vote = useMutation({
mutationFn: (mode: VoteMode) => {
const fd = new FormData();
fd.append("mode", mode);
return api.post<VoteTally>(`/entry/${pid}/vote`, fd);
},
onMutate: async (mode) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<VoteTally>(key);
if (prev) qc.setQueryData(key, toggled(prev, mode));
return { prev };
},
onError: (_err, _mode, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
},
onSuccess: (data) => qc.setQueryData(key, data),
});
const t = tally.data;
let counts = "– / –";
if (vote.isError) counts = errMsg(vote.error, "Fehler");
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
else if (t) counts = `${t.left} / ${t.right}`;
const button = (mode: VoteMode, label: string) => (
<button
className={t?.selected === mode ? "ghost selected" : "ghost"}
data-mode={mode}
onClick={() => vote.mutate(mode)}
>
{label}
</button>
);
return (
<div className="row">
{button("left", "Links")}
<span className="muted">{counts}</span>
{button("right", "Rechts")}
</div>
);
}