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,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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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() {
|
export function FeedPage() {
|
||||||
return null;
|
return <Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user