- 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
65 lines
2.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|