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
+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>
</>
);
}