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:
co-authored by
Claude Opus 5.5
parent
ca65ddcacc
commit
728ac9b45e
@@ -10,8 +10,13 @@ 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 }) {
|
||||
// API-Pfad der Seite. as ist das Container-Element wie im alten Markup:
|
||||
// <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)),
|
||||
@@ -48,11 +53,11 @@ export function Feed({ queryKey, urlFor }: { queryKey: unknown[]; urlFor: (page:
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="feed">
|
||||
<Container 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>
|
||||
</Container>
|
||||
<div id="sentinel" ref={sentinel}></div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@ import react from "@vitejs/plugin-react";
|
||||
// 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
|
||||
// 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 proxy = { target: backend, changeOrigin: false };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": backend,
|
||||
"/static": backend,
|
||||
"/api": proxy,
|
||||
"/static": proxy,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user