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
+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() {
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>
</>
);
}