Files
Kontrollverlust/frontend/src/components/Feed.tsx
T
irrlichtandClaude Opus 5.5 df0cfec132 Code-Review: tote Stellen entfernt, Umwege vereinfacht
- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt
- Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries
  direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für
  Cookie-Löschen und Mediennamen, usernameTaken
- Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard
  aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt
- Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:03:22 +02:00

65 lines
2.5 KiB
TypeScript

import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { Entry } from "../types";
import { Discussion } 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. as ist das Container-Element: <section> auf der
// Startseite, <div> innerhalb des Profils.
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
queryKey: unknown[];
urlFor: (page: number) => string;
as?: "section" | "div";
}) {
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 (
<>
<Container id="feed">
{entries.map((e) => <Discussion 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>}
</Container>
<div id="sentinel" ref={sentinel}></div>
</>
);
}