React-Frontend: Profilseite und Statistik, Dev-Proxy-Fix

- UserPage mit Profilkopf, Einstellungen (Avatar, Name, Konto löschen)
  und Nutzer-Feed im unveränderten Markup
- StatsPage mit Kacheln und Balken wie bisher
- Feed-Container wählbar (section/div) wie im alten Markup
- Vite-Proxy mit changeOrigin: false -- sonst scheitert jeder POST im
  Dev-Betrieb am Origin-Check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:44:40 +02:00
co-authored by Claude Opus 5.5
parent ca65ddcacc
commit 728ac9b45e
4 changed files with 259 additions and 10 deletions
+9 -4
View File
@@ -10,8 +10,13 @@ const maxPage = 100;
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im // Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den // Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
// API-Pfad der Seite. // API-Pfad der Seite. as ist das Container-Element wie im alten Markup:
export function Feed({ queryKey, urlFor }: { queryKey: unknown[]; urlFor: (page: number) => string }) { // <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({ const feed = useInfiniteQuery({
queryKey, queryKey,
queryFn: ({ pageParam }) => api.get<Entry[]>(urlFor(pageParam)), queryFn: ({ pageParam }) => api.get<Entry[]>(urlFor(pageParam)),
@@ -48,11 +53,11 @@ export function Feed({ queryKey, urlFor }: { queryKey: unknown[]; urlFor: (page:
return ( return (
<> <>
<section id="feed"> <Container id="feed">
{entries.map((e) => <EntryCard key={e.pid} entry={e} />)} {entries.map((e) => <EntryCard key={e.pid} entry={e} />)}
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>} {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>} {feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
</section> </Container>
<div id="sentinel" ref={sentinel}></div> <div id="sentinel" ref={sentinel}></div>
</> </>
); );
+102 -2
View File
@@ -1,4 +1,104 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt. import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { StatsDetail } from "../types";
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function NumCard({ value, label }: { value: number; label: string }) {
return (
<div>
<span className="num">{value}</span>
<span className="muted">{label}</span>
</div>
);
}
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
// ist relativ zum größten Wert.
function Bars({ items, formatLabel }: {
items: { label: string; value: number }[];
formatLabel?: (label: string) => string;
}) {
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
const max = Math.max(...items.map((it) => it.value), 1);
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
const valWidth = Math.max(...items.map((it) => String(it.value).length));
return items.map((it) => (
<div className="bar-row" key={it.label}>
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
</div>
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
</div>
));
}
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
export function StatsPage() { export function StatsPage() {
return null; const stats = useQuery({
queryKey: ["stats"],
queryFn: () => api.get<StatsDetail>("/stats/detail"),
});
const s = stats.data;
// Backend liefert die Tage absteigend -> für die Achse drehen.
const daily = s ? s.daily.slice().reverse() : [];
return (
<section>
<h2>Statistik</h2>
<h3>Auf einen Blick</h3>
<div id="stat-overview" className="statgrid">
{stats.isError && <p className="msg">{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}</p>}
{s && (
<>
<NumCard value={s.users} label={s.users === 1 ? "Profil" : "Profile"} />
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.votes.left} label="Stimmen Links" />
<NumCard value={s.votes.right} label="Stimmen Rechts" />
</>
)}
</div>
<h3>Reichweite</h3>
<p className="muted">
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
</p>
<div id="stat-reach" className="statgrid">
{s && (
<>
<NumCard value={s.impressions_total} label="Aufrufe gesamt" />
<NumCard value={s.visitors_total} label="Netze gesamt" />
</>
)}
</div>
<h3>Netze nach Betreiber</h3>
<p className="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" className="bars">
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
</div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
</div>
<h3>Verschiedene Netze pro Tag</h3>
<div id="stat-networks" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
</div>
</section>
);
} }
+141 -2
View File
@@ -1,4 +1,143 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt. import { useState, type FormEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useAuthChanged, useMe } from "../auth";
import { avatarUrl } from "../format";
import { Feed } from "../components/Feed";
import type { Profile } from "../types";
// Beim Wechsel zu einem anderen Profil frisch anfangen (Meldungen, Formulare).
export function UserPage() { export function UserPage() {
return null; const { username = "" } = useParams();
return <ProfileView key={username} username={username} />;
}
function ProfileView({ username }: { username: string }) {
const profile = useQuery({
queryKey: ["profile", username],
queryFn: () => api.get<Profile>(`/u/${encodeURIComponent(username)}/info`),
});
const me = useMe().data;
// Nach einem Upload das neue Bild sofort zeigen: Zeitstempel am URL
// umgeht den Browser-Cache.
const [avatarBust, setAvatarBust] = useState("");
const info = profile.data;
let name = "";
if (profile.isError) name = errMsg(profile.error, "Profil konnte nicht geladen werden.");
else if (info) name = info.username;
const isOwner = !!me && !!info && me.username === info.username;
return (
<section id="profile">
<div className="profile-head">
<img
id="profile-avatar"
className="avatar avatar-lg"
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"}
alt=""
/>
<div>
<h2 id="profile-name">{name}</h2>
<p id="profile-info" className="muted">
{info && `Mitglied seit ${new Date(info.created_at * 1000).toLocaleDateString("de-DE")}`}
</p>
</div>
</div>
<div id="settings" hidden={!isOwner}>
{isOwner && <Settings onAvatar={() => setAvatarBust(`?t=${Date.now()}`)} />}
</div>
{info && (
<Feed
as="div"
queryKey={["feed", "user", username]}
urlFor={(page) => `/u/${encodeURIComponent(username)}/feed/${page}`}
/>
)}
</section>
);
}
// useFormAction verdrahtet ein Einstellungsformular: POST auf path, bei Erfolg
// onSuccess mit Formular und Antwort, bei Fehler die Meldung unter dem Formular.
function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTMLFormElement, data: T) => void) {
const m = useMutation({ mutationFn: (fd: FormData) => api.post<T>(path, fd) });
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
m.mutate(new FormData(form), { onSuccess: (data) => onSuccess(form, data) });
};
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" };
}
// Settings: nur für den Eigentümer. Die {" "} bilden die Leerzeichen des alten
// Markups nach -- Inputs und Knöpfe sind inline.
function Settings({ onAvatar }: { onAvatar: () => void }) {
const qc = useQueryClient();
const navigate = useNavigate();
const authChanged = useAuthChanged();
// Neues Bild und neuer Name tauchen auch in den Beitragskarten auf -> die
// Feeds und das eigene Konto neu laden.
const refresh = () => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["thread"] });
qc.invalidateQueries({ queryKey: ["profile"] });
return authChanged();
};
const avatar = useFormAction<{ avatar: string }>("/user/avatar", "Hochladen fehlgeschlagen.", (form) => {
form.reset();
onAvatar();
refresh();
});
const rename = useFormAction<{ username: string }>("/user/rename", "Umbenennen fehlgeschlagen.", async (_form, data) => {
await refresh();
navigate(`/u/${encodeURIComponent(data.username)}`);
});
const del = useFormAction("/user/delete", "Löschen fehlgeschlagen.", async () => {
await refresh();
navigate("/");
});
return (
<>
<h2>Einstellungen</h2>
<details>
<summary>Profilbild ändern</summary>
<form id="avatar-form" onSubmit={avatar.onSubmit}>
<input name="avatar" type="file" accept="image/*" />{" "}
<button type="submit" disabled={avatar.busy}>Bild hochladen</button>
</form>
<p className="msg" id="avatar-msg">{avatar.msg}</p>
</details>
<details>
<summary>Name ändern</summary>
<form id="rename-form" onSubmit={rename.onSubmit}>
<input name="user" type="text" placeholder="Neuer Nutzername" autoComplete="username" />{" "}
<button type="submit" disabled={rename.busy}>Namen ändern</button>
</form>
<p className="msg" id="rename-msg">{rename.msg}</p>
</details>
<details>
<summary>Konto löschen</summary>
<form
id="delete-form"
onSubmit={(e) => {
if (confirm("Konto wirklich unwiderruflich löschen?")) del.onSubmit(e);
else e.preventDefault();
}}
>
<p className="muted">Löscht dein Konto samt Beiträgen und Votes unwiderruflich.</p>{" "}
<input name="pass1" type="password" placeholder="Passwort bestätigen" autoComplete="current-password" />{" "}
<button type="submit" disabled={del.busy}>Konto endgültig löschen</button>
</form>
<p className="msg" id="delete-msg">{del.msg}</p>
</details>
</>
);
} }
+7 -2
View File
@@ -4,14 +4,19 @@ import react from "@vitejs/plugin-react";
// Im Dev-Betrieb läuft das Go-Backend separat (siehe dev.env, Port 8081). // Im Dev-Betrieb läuft das Go-Backend separat (siehe dev.env, Port 8081).
// Vite reicht /api und /static dorthin durch -- für den Browser bleibt alles // Vite reicht /api und /static dorthin durch -- für den Browser bleibt alles
// eine Origin, Session-Cookie und Origin-Check funktionieren unverändert. // eine Origin, Session-Cookie und Origin-Check funktionieren unverändert.
//
// changeOrigin: false ist Pflicht: die Kurzform (nur URL) setzt es auf true
// und schreibt den Host-Header auf das Backend um. Dann passt der Origin des
// Browsers nicht mehr zum Host und checkOrigin weist jeden POST als CSRF ab.
const backend = process.env.KVER_BACKEND ?? "http://127.0.0.1:8081"; const backend = process.env.KVER_BACKEND ?? "http://127.0.0.1:8081";
const proxy = { target: backend, changeOrigin: false };
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": backend, "/api": proxy,
"/static": backend, "/static": proxy,
}, },
}, },
}); });