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