diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 657e7b6..fb2edc7 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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: +//
auf der Startseite,
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(urlFor(pageParam)), @@ -48,11 +53,11 @@ export function Feed({ queryKey, urlFor }: { queryKey: unknown[]; urlFor: (page: 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/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 5ccf32b..8e33835 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -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 ( +
+ {value} + {label} +
+ ); +} + +// 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

Noch keine Daten.

; + 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) => ( +
+ {formatLabel ? formatLabel(it.label) : it.label} +
+
+
+ {it.value} +
+ )); +} + +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("/stats/detail"), + }); + const s = stats.data; + // Backend liefert die Tage absteigend -> für die Achse drehen. + const daily = s ? s.daily.slice().reverse() : []; + + return ( +
+

Statistik

+ +

Auf einen Blick

+
+ {stats.isError &&

{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}

} + {s && ( + <> + + + + + + + + )} +
+ +

Reichweite

+

+ Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32). + „Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung. +

+
+ {s && ( + <> + + + + )} +
+ +

Netze nach Betreiber

+

+ Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN. +

+
+ {s && ({ label: a.asn, value: a.hits }))} />} +
+ +

Aufrufe pro Tag

+
+ {s && ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />} +
+ +

Verschiedene Netze pro Tag

+
+ {s && ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />} +
+
+ ); } diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index 3959ba4..3ce5187 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -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 ; +} + +function ProfileView({ username }: { username: string }) { + const profile = useQuery({ + queryKey: ["profile", username], + queryFn: () => api.get(`/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 ( +
+
+ +
+

{name}

+

+ {info && `Mitglied seit ${new Date(info.created_at * 1000).toLocaleDateString("de-DE")}`} +

+
+
+ + + + {info && ( + `/u/${encodeURIComponent(username)}/feed/${page}`} + /> + )} +
+ ); +} + +// useFormAction verdrahtet ein Einstellungsformular: POST auf path, bei Erfolg +// onSuccess mit Formular und Antwort, bei Fehler die Meldung unter dem Formular. +function useFormAction(path: string, fallback: string, onSuccess: (form: HTMLFormElement, data: T) => void) { + const m = useMutation({ mutationFn: (fd: FormData) => api.post(path, fd) }); + const onSubmit = (e: FormEvent) => { + 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 ( + <> +

Einstellungen

+
+ Profilbild ändern +
+ {" "} + +
+

{avatar.msg}

+
+
+ Name ändern +
+ {" "} + +
+

{rename.msg}

+
+
+ Konto löschen +
{ + if (confirm("Konto wirklich unwiderruflich löschen?")) del.onSubmit(e); + else e.preventDefault(); + }} + > +

Löscht dein Konto samt Beiträgen und Votes unwiderruflich.

{" "} + {" "} + +
+

{del.msg}

+
+ + ); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7123c35..06da37b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, }, }, });