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:
co-authored by
Claude Opus 5.5
parent
37279ac90d
commit
bd5c59bcad
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user