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
+67
View File
@@ -0,0 +1,67 @@
import type { MouseEvent } from "react";
import { Link, useNavigate } from "react-router";
import type { Entry } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
import { useBump } from "./Bump";
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link
// oder Knopf darin.
function useOpenOnClick(pid: number) {
const navigate = useNavigate();
return (ev: MouseEvent) => {
if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`);
};
}
export function EntryCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const when = formatTime(e.created_at);
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
return (
<article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {when}</span></div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">[deleted]</div>
</div>
</div>
</article>
);
}
return <LiveCard entry={e} when={when} open={open} />;
}
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) {
const bump = useBump(e);
const profile = `/u/${encodeURIComponent(e.username)}`;
return (
<article className="card" onClick={open}>
<div className="muted byline">
<Link to={profile}>
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
</Link>
<span>
<Link to={profile}>{e.username}</Link> · {when}
<span className="cooldown">{bump.cooldown}</span>
</span>
</div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">{linkify(e.content)}</div>
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
<div className="interactions">
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
<Votes pid={e.pid} />
{bump.button}
</div>
</div>
</div>
</article>
);
}