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
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ApiError, api, errMsg } from "../api";
import type { BumpResult, Entry } from "../types";
const day = 86400;
const nowSec = () => Math.floor(Date.now() / 1000);
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
export function cooldownText(retryAfter: number): string {
return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
}
// useBump hält den Repost-Zustand eines Beitrags. Der Cooldown wächst pro
// Beitrag linear (Backend, siehe handleBump); hier wird nur angezeigt und auf
// 429 reagiert. Eine Live-Aktualisierung gibt es bewusst nicht: der Zustand
// stammt aus den Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
// Der Hinweis steht in der Byline, der Knopf unten -- deshalb ein Hook statt
// einer Komponente.
export function useBump(e: Entry) {
const initial = e.last_bump + e.bump_count * day - nowSec();
const [count, setCount] = useState(e.bump_count);
const [retryAfter, setRetryAfter] = useState(e.bump_count > 0 && initial > 0 ? initial : 0);
const [error, setError] = useState("");
const bump = useMutation({
mutationFn: () => api.post<BumpResult>(`/entry/${e.pid}/bump`),
onSuccess: (r) => {
setCount(r.bump_count);
setRetryAfter(r.retry_after || day);
},
onError: (err) => {
if (err instanceof ApiError && err.status === 429) {
setRetryAfter(Number(err.extra.retry_after) || day);
} else {
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Knopf frei lassen.
setError(errMsg(err, "Fehler"));
}
},
});
const locked = retryAfter > 0;
return {
cooldown: locked ? cooldownText(retryAfter) : "",
button: (
<button
className="bump"
type="button"
disabled={locked || bump.isPending}
onClick={() => bump.mutate()}
>
{error || `Repost (${count})`}
</button>
),
};
}
+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>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { Entry } from "../types";
import { EntryCard } from "./EntryCard";
// Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder
// Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen.
const maxPage = 100;
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
// API-Pfad der Seite.
export function Feed({ queryKey, urlFor }: { queryKey: unknown[]; urlFor: (page: number) => string }) {
const feed = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => api.get<Entry[]>(urlFor(pageParam)),
initialPageParam: 0,
getNextPageParam: (last, pages) =>
last.length && pages.length <= maxPage ? pages.length : undefined,
});
const sentinel = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetching, isError, fetchNextPage } = feed;
const canLoad = hasNextPage && !isFetching && !isError;
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach. Der Observer
// wird nach jeder geladenen Seite neu angelegt und meldet dabei den
// aktuellen Zustand: füllt die Seite den Viewport nicht, liegt der Sentinel
// noch im Sichtbereich und es wird direkt weitergeladen.
useEffect(() => {
const el = sentinel.current;
if (!el || !canLoad) return;
const observer = new IntersectionObserver(
(items) => {
if (items.some((i) => i.isIntersecting)) fetchNextPage();
},
{ rootMargin: "300px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [canLoad, fetchNextPage]);
// Zwischen zwei Seiten kann ein Beitrag durch einen Repost nach vorn
// rutschen und auf der nächsten Seite erneut auftauchen -- nur einmal zeigen.
const seen = new Set<number>();
const entries = (feed.data?.pages.flat() ?? []).filter((e) => !seen.has(e.pid) && seen.add(e.pid));
return (
<>
<section id="feed">
{entries.map((e) => <EntryCard key={e.pid} entry={e} />)}
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>}
{feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
</section>
<div id="sentinel" ref={sentinel}></div>
</>
);
}
+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>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
export function avatarUrl(path: string): string {
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
}
export function formatTime(unix: number): string {
return new Date(unix * 1000).toLocaleString("de-DE");
}
const urlPattern = /https?:\/\/[^\s<]+/g;
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. React
// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein
// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte.
export function linkify(text: string): ReactNode[] {
const out: ReactNode[] = [];
text.split("\n").forEach((line, i) => {
if (i > 0) out.push(<br key={`br${i}`} />);
let last = 0;
for (const m of line.matchAll(urlPattern)) {
const start = m.index;
if (start > last) out.push(line.slice(last, start));
out.push(
<a key={`a${i}-${start}`} href={m[0]} target="_blank" rel="noopener">
{m[0]}
</a>,
);
last = start + m[0].length;
}
if (last < line.length) out.push(line.slice(last));
});
return out;
}
+4 -2
View File
@@ -1,4 +1,6 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
import { Feed } from "../components/Feed";
// Login/Registrierung und das Formular für neue Beiträge folgen in Schritt 4.
export function FeedPage() {
return null;
return <Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />;
}