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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user