diff --git a/web/css/app.css b/web/css/app.css
index e510f0e..d46aef6 100644
--- a/web/css/app.css
+++ b/web/css/app.css
@@ -299,3 +299,57 @@ hr {
border: none;
border-top: 1px solid #ccc;
}
+
+/* --- Statistik-Seite --- */
+.statgrid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem 1.8rem;
+ padding: 0.3rem 0.5rem 0.6rem;
+}
+.statgrid > div {
+ display: flex;
+ flex-direction: column;
+}
+.statgrid .num {
+ font-size: 1.8rem;
+ line-height: 1.1;
+}
+
+/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS
+ gesetzt (CSSOM, kein Inline-style -> CSP-konform). */
+.bars {
+ padding: 0.3rem 0.5rem 0.6rem;
+}
+.bar-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.15rem 0;
+}
+.bar-label {
+ flex: 0 0 7rem;
+ text-align: right;
+ font-size: 0.95rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.bar-track {
+ flex: 1;
+ background: #eee;
+}
+.bar-fill {
+ height: 1.1rem;
+ background: gray;
+}
+.bar-value {
+ flex: 0 0 auto;
+ font-size: 0.95rem;
+}
+
+/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */
+#stat-asns .bar-label {
+ flex-basis: 13rem;
+ white-space: normal;
+}
diff --git a/web/entry.html b/web/entry.html
index e810689..3bf939d 100644
--- a/web/entry.html
+++ b/web/entry.html
@@ -11,7 +11,8 @@
Impressum -
Datenschutz -
- Beitragsordnung
+ Beitragsordnung -
+ Statistik
Kontrollverlust
diff --git a/web/index.html b/web/index.html
index b429d4e..44853bf 100644
--- a/web/index.html
+++ b/web/index.html
@@ -11,7 +11,8 @@
Impressum -
Datenschutz -
- Beitragsordnung
+ Beitragsordnung -
+ Statistik
Kontrollverlust
diff --git a/web/js/stats.js b/web/js/stats.js
new file mode 100644
index 0000000..a887592
--- /dev/null
+++ b/web/js/stats.js
@@ -0,0 +1,102 @@
+"use strict";
+
+// Statistik-Seite: holt die Aggregate von /stats/detail und rendert Zahlen +
+// einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden
+// per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von
+// der strikten CSP nicht betroffen.
+
+function plural(n, one, many) {
+ return `${n} ${n === 1 ? one : many}`;
+}
+
+// numCard baut eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
+function numCard(value, label) {
+ const cell = document.createElement("div");
+ const num = document.createElement("span");
+ num.className = "num";
+ num.textContent = value;
+ const cap = document.createElement("span");
+ cap.className = "muted";
+ cap.textContent = label;
+ cell.append(num, cap);
+ return cell;
+}
+
+// renderBars zeichnet eine Liste {label, value} als horizontale Balken. Die
+// Breite ist relativ zum größten Wert.
+function renderBars(container, items, formatLabel) {
+ container.textContent = "";
+ if (items.length === 0) {
+ const p = document.createElement("p");
+ p.className = "muted";
+ p.textContent = "Noch keine Daten.";
+ container.append(p);
+ return;
+ }
+ const max = Math.max(...items.map((it) => it.value), 1);
+ for (const it of items) {
+ const row = document.createElement("div");
+ row.className = "bar-row";
+
+ const label = document.createElement("span");
+ label.className = "bar-label";
+ label.textContent = formatLabel ? formatLabel(it.label) : it.label;
+
+ const track = document.createElement("div");
+ track.className = "bar-track";
+ const fill = document.createElement("div");
+ fill.className = "bar-fill";
+ fill.style.width = `${(it.value / max) * 100}%`;
+ track.append(fill);
+
+ const val = document.createElement("span");
+ val.className = "bar-value";
+ val.textContent = it.value;
+
+ row.append(label, track, val);
+ container.append(row);
+ }
+}
+
+async function render() {
+ const s = await api.get("/stats/detail");
+
+ // --- Auf einen Blick ---
+ const overview = document.getElementById("stat-overview");
+ overview.append(
+ numCard(s.users ?? 0, s.users === 1 ? "Profil" : "Profile"),
+ numCard(s.entries ?? 0, "Beiträge"),
+ numCard(s.toplevel ?? 0, "davon Threads"),
+ numCard(s.replies ?? 0, "davon Antworten"),
+ numCard(s.votes?.left ?? 0, "Stimmen Links"),
+ numCard(s.votes?.right ?? 0, "Stimmen Rechts"),
+ );
+
+ // --- Reichweite ---
+ document.getElementById("stat-reach").append(
+ numCard(s.impressions_total ?? 0, "Aufrufe gesamt"),
+ numCard(s.visitors_total ?? 0, "Netze gesamt"),
+ );
+
+ // --- Netze nach Betreiber (ASN) ---
+ renderBars(
+ document.getElementById("stat-asns"),
+ (s.top_asns ?? []).map((a) => ({ label: a.asn, value: a.hits })),
+ );
+
+ // --- Aufrufe pro Tag (Backend liefert absteigend -> für die Achse drehen) ---
+ const daily = (s.daily ?? []).slice().reverse();
+ renderBars(
+ document.getElementById("stat-daily"),
+ daily.map((d) => ({ label: d.day, value: d.impressions })),
+ (day) => day.slice(5), // "MM-DD" reicht auf der Achse
+ );
+
+ // --- Beliebteste Seiten ---
+ renderBars(
+ document.getElementById("stat-pages"),
+ (s.top_pages ?? []).map((p) => ({ label: p.path, value: p.hits })),
+ );
+}
+
+render();
diff --git a/web/stats.html b/web/stats.html
new file mode 100644
index 0000000..95516df
--- /dev/null
+++ b/web/stats.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Statistik – Kontrollverlust
+
+
+
+
+
+
+
+ Statistik
+
+ Auf einen Blick
+
+
+ Reichweite
+
+ Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
+ „Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
+
+
+
+ Netze nach Betreiber
+
+ Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
+
+
+
+ Aufrufe pro Tag
+
+
+ Beliebteste Seiten
+
+
+
+
+
+
+
+
diff --git a/web/user.html b/web/user.html
index 8d6d4c0..0a1ce19 100644
--- a/web/user.html
+++ b/web/user.html
@@ -11,7 +11,8 @@
Impressum -
Datenschutz -
- Beitragsordnung
+ Beitragsordnung -
+ Statistik
Kontrollverlust