From bd5c59bcad6131765a325c589348dc086152384e Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 26 Sep 2026 23:30:17 +0200 Subject: [PATCH] React-Frontend: Feed mit Beitragskarte, Votes und Repost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- frontend/src/components/Bump.tsx | 57 ++++++++++++++++++++++ frontend/src/components/EntryCard.tsx | 67 ++++++++++++++++++++++++++ frontend/src/components/Feed.tsx | 59 +++++++++++++++++++++++ frontend/src/components/Votes.tsx | 69 +++++++++++++++++++++++++++ frontend/src/format.tsx | 35 ++++++++++++++ frontend/src/pages/FeedPage.tsx | 6 ++- 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Bump.tsx create mode 100644 frontend/src/components/EntryCard.tsx create mode 100644 frontend/src/components/Feed.tsx create mode 100644 frontend/src/components/Votes.tsx create mode 100644 frontend/src/format.tsx diff --git a/frontend/src/components/Bump.tsx b/frontend/src/components/Bump.tsx new file mode 100644 index 0000000..a78e58c --- /dev/null +++ b/frontend/src/components/Bump.tsx @@ -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(`/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: ( + + ), + }; +} diff --git a/frontend/src/components/EntryCard.tsx b/frontend/src/components/EntryCard.tsx new file mode 100644 index 0000000..42dbfd1 --- /dev/null +++ b/frontend/src/components/EntryCard.tsx @@ -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 ( +
+
[deleted] · {when}
+
+
+
+
[deleted]
+
+
+
+ ); + } + return ; +} + +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 ( +
+
+ + + + + {e.username} · {when} + {bump.cooldown} + +
+
+
+
+
{linkify(e.content)}
+ {e.filepath && } +
+ Antworten ({e.reply_count}) + + {bump.button} +
+
+
+
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx new file mode 100644 index 0000000..657e7b6 --- /dev/null +++ b/frontend/src/components/Feed.tsx @@ -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(urlFor(pageParam)), + initialPageParam: 0, + getNextPageParam: (last, pages) => + last.length && pages.length <= maxPage ? pages.length : undefined, + }); + const sentinel = useRef(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(); + const entries = (feed.data?.pages.flat() ?? []).filter((e) => !seen.has(e.pid) && seen.add(e.pid)); + + return ( + <> +
+ {entries.map((e) => )} + {feed.isError &&

{errMsg(feed.error, "Feed konnte nicht geladen werden.")}

} + {feed.isSuccess && !hasNextPage &&

YOU REACHED THE END!

} +
+
+ + ); +} diff --git a/frontend/src/components/Votes.tsx b/frontend/src/components/Votes.tsx new file mode 100644 index 0000000..f071474 --- /dev/null +++ b/frontend/src/components/Votes.tsx @@ -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(`/entry/${pid}/votes`), + }); + + const vote = useMutation({ + mutationFn: (mode: VoteMode) => { + const fd = new FormData(); + fd.append("mode", mode); + return api.post(`/entry/${pid}/vote`, fd); + }, + onMutate: async (mode) => { + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(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) => ( + + ); + + return ( +
+ {button("left", "Links")} + {counts} + {button("right", "Rechts")} +
+ ); +} diff --git a/frontend/src/format.tsx b/frontend/src/format.tsx new file mode 100644 index 0000000..c259bef --- /dev/null +++ b/frontend/src/format.tsx @@ -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
. 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(
); + 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( + + {m[0]} + , + ); + last = start + m[0].length; + } + if (last < line.length) out.push(line.slice(last)); + }); + return out; +} diff --git a/frontend/src/pages/FeedPage.tsx b/frontend/src/pages/FeedPage.tsx index 13885e7..9658aca 100644 --- a/frontend/src/pages/FeedPage.tsx +++ b/frontend/src/pages/FeedPage.tsx @@ -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 `/entry/feed/${page}`} />; }