- 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
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
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() {
|
|
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>
|
|
</>
|
|
);
|
|
}
|