From 9cac33d1676feb1dc0722fca3102c2c37b7fa930 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 26 Sep 2026 23:49:20 +0200 Subject: [PATCH] React-Frontend ausliefern, altes Vanilla-Frontend entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend.go: Seitenrouten liefern die index.html (no-cache), gehashte Assets unter /assets dauerhaft cachebar, sonst echtes 404 - /stats als Seite, /stats.html leitet per 301 weiter - Containerfile: Node-Stage baut das Frontend nach web/ - Vite baut nach ../web (gitignored); web/ (Vanilla-JS) gelöscht - notes/frontend.md, deploy.md angepasst Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- .containerignore | 2 +- .gitignore | 2 +- Containerfile | 12 +- entry.go | 6 - frontend.go | 35 ++++ frontend/vite.config.ts | 7 + main.go | 41 ++-- notes/deploy.md | 7 +- notes/frontend.md | 46 ++++ stats.go | 7 - user.go | 6 - web/.well-known/security.txt | 6 - web/css/app.css | 395 ----------------------------------- web/entry.html | 42 ---- web/index.html | 69 ------ web/js/app.js | 122 ----------- web/js/entry.js | 97 --------- web/js/feed.js | 289 ------------------------- web/js/stats.js | 116 ---------- web/js/user.js | 69 ------ web/stats.html | 52 ----- web/user.html | 69 ------ 22 files changed, 129 insertions(+), 1368 deletions(-) create mode 100644 frontend.go create mode 100644 notes/frontend.md delete mode 100644 web/.well-known/security.txt delete mode 100644 web/css/app.css delete mode 100644 web/entry.html delete mode 100644 web/index.html delete mode 100644 web/js/app.js delete mode 100644 web/js/entry.js delete mode 100644 web/js/feed.js delete mode 100644 web/js/stats.js delete mode 100644 web/js/user.js delete mode 100644 web/stats.html delete mode 100644 web/user.html diff --git a/.containerignore b/.containerignore index 16c886c..40bb3cb 100644 --- a/.containerignore +++ b/.containerignore @@ -16,4 +16,4 @@ migrate.sh Containerfile .containerignore frontend/node_modules/ -frontend/dist/ +web/ diff --git a/.gitignore b/.gitignore index 006e20b..1ada474 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ __pycache__/ static/media/ prod_backup/ frontend/node_modules/ -frontend/dist/ +/web/ diff --git a/Containerfile b/Containerfile index 907dac3..e887373 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,13 @@ +# Frontend-Stage: React-Build nach /src/web (siehe frontend/vite.config.ts). +# Erst nur die Paketdateien, damit npm ci bei reinen Code-Änderungen aus dem +# Cache kommt. +FROM docker.io/library/node:24-alpine AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + # Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den # einmaligen Import sind pures Go). FROM docker.io/library/golang:1.26-alpine AS build @@ -12,7 +22,7 @@ FROM docker.io/library/alpine:3.22 RUN adduser -D -u 1000 kver WORKDIR /app COPY --from=build /kver /usr/local/bin/kver -COPY web/ web/ +COPY --from=frontend /src/web web/ COPY static/ static/ # data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import); # static/media: Uploads. Beide als Volume persistieren. diff --git a/entry.go b/entry.go index 2e530fe..4856eb8 100644 --- a/entry.go +++ b/entry.go @@ -150,12 +150,6 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) error { return nil } -// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten -// dann über /entry/{pid}/thread. -func serveEntryPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "web/entry.html") -} - var ( errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden") errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.") diff --git a/frontend.go b/frontend.go new file mode 100644 index 0000000..12f6f3c --- /dev/null +++ b/frontend.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + "strings" +) + +// webDir enthält das gebaute React-Frontend (frontend/ -> npm run build): +// index.html plus gehashte Dateien unter assets/. +const webDir = "web" + +// serveApp liefert die index.html des Frontends; das Routing übernimmt der +// Client. Nur für die Seitenrouten des Frontends registriert (siehe routes) -- +// beliebige andere Pfade bleiben ein echtes 404, statt als Seite zu gelten und +// in der Impression-Statistik zu landen. no-cache, damit nach einem Deploy +// sofort die neue index.html mit den neuen Asset-Namen geladen wird. +func serveApp(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + http.ServeFile(w, r, webDir+"/index.html") +} + +// serveAssets liefert die gebauten JS/CSS-Dateien. Ihre Namen enthalten einen +// Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse +// gibt es nicht zu sehen (kein Listing). +func serveAssets() http.Handler { + files := http.FileServer(http.Dir(webDir)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + files.ServeHTTP(w, r) + }) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 06da37b..90e3a43 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,6 +13,13 @@ const proxy = { target: backend, changeOrigin: false }; export default defineConfig({ plugins: [react()], + // Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go). + // emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/ + // liegt. + build: { + outDir: "../web", + emptyOutDir: true, + }, server: { proxy: { "/api": proxy, diff --git a/main.go b/main.go index 72d3583..01bade2 100644 --- a/main.go +++ b/main.go @@ -19,9 +19,9 @@ import ( ) func main() { - // KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle - // relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit - // nicht mehr an das Start-CWD gebunden. + // KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per + // Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert -- + // die App ist damit nicht mehr an das Start-CWD gebunden. if dir := os.Getenv("KVER_DIR"); dir != "" { if err := os.Chdir(dir); err != nil { log.Fatalf("KVER_DIR: %v", err) @@ -102,8 +102,8 @@ func envOr(key, fallback string) string { // Tests identisch verwendet. // // Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405, -// Rate-Limit und Panics. Der Rest liefert die HTML-Seiten und Dateien des -// Frontends. +// Rate-Limit und Panics. Der Rest liefert das Frontend (frontend.go) und +// statische Dateien. func routes() http.Handler { r := chi.NewRouter() // RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit @@ -166,22 +166,27 @@ func routes() http.Handler { }) }) - // --- HTML-Seiten --- - // Mit Impression-Tracking (anonymisiert, siehe stats.go). Nur echte - // Seitenaufrufe, nicht API/Assets. - r.With(trackImpression).Get("/", serveIndex) - r.With(trackImpression).Get("/e/{pid}", serveEntryPage) - r.With(trackImpression).Get("/u/{username}", serveUserPage) + // --- Frontend (React) --- + // Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/ + // main.tsx) und liefern alle dieselbe index.html. Impression-Tracking + // (anonymisiert, siehe stats.go) nur auf echten Seitenaufrufen, nicht auf + // API/Assets; die Statistik-Seite zählt wie bisher nicht mit. + r.With(trackImpression).Get("/", serveApp) + r.With(trackImpression).Get("/e/{pid}", serveApp) + r.With(trackImpression).Get("/u/{username}", serveApp) + r.Get("/stats", serveApp) + // Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen). + r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/stats", http.StatusMovedPermanently) + }) + r.Handle("/assets/*", serveAssets()) - // Hochgeladene Medien + alte statische Assets. nosniff (global via - // secHeaders) verhindert, dass der Browser eine durchgereichte (nicht - // re-kodierte) Datei als HTML/JS interpretiert -> schließt - // Polyglot/Stored-XSS über Bild-Uploads. + // Hochgeladene Medien + statische Dateien (Schriften, Rechtstexte). + // nosniff (global via secHeaders) verhindert, dass der Browser eine + // durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert -> + // schließt Polyglot/Stored-XSS über Bild-Uploads. r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - // Frontend-Dateien (JS, CSS, weitere Seiten) - r.Handle("/*", http.FileServer(http.Dir("web"))) - return r } diff --git a/notes/deploy.md b/notes/deploy.md index f55c19f..844af7b 100644 --- a/notes/deploy.md +++ b/notes/deploy.md @@ -27,8 +27,11 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren podman build -t kver . ``` -Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit- -Image ist Alpine mit unprivilegiertem Nutzer. +Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer +Node-Stage gebaut (`npm ci && npm run build`), das Go-Binary statisch (CGO +aus); das Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer und enthält +nur Binary, gebautes Frontend (`web/`) und `static/`. Auf dem Server wird +also weder Node noch Go gebraucht, nur Podman. Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar: Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der diff --git a/notes/frontend.md b/notes/frontend.md new file mode 100644 index 0000000..137a0e8 --- /dev/null +++ b/notes/frontend.md @@ -0,0 +1,46 @@ +# Frontend (React) + +Quellcode in `frontend/`: Vite, React, TypeScript, React Router, TanStack Query. + +**Optik:** bewusst brutalistisch-roh. Markup, IDs und Klassen entsprechen dem +früheren Vanilla-Frontend, `frontend/src/app.css` ist das einzige Stylesheet. +Umbauten ändern Funktion, nicht Aussehen. + +## Entwickeln + +Zwei Terminals: + +```sh +set -a; . ./dev.env; set +a; go run . # Backend auf :8081 +cd frontend && npm run dev # http://localhost:5173 +``` + +Vite reicht `/api` und `/static` ans Backend durch (`frontend/vite.config.ts`, +`changeOrigin: false` ist Pflicht, sonst scheitert jeder POST am Origin-Check). + +## Bauen + +`npm run build` in `frontend/` prüft die Typen und schreibt nach `../web/` +(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also +auch ohne Vite: `npm run build`, dann `go run .`. + +## Aufbau + +| Datei | Zweck | +|-------|-------| +| `src/api.ts` | API-Client; jeder Nicht-2xx-Status wird zu `ApiError` | +| `src/types.ts` | Typen der API-Antworten (Referenz: `notes/api.md`) | +| `src/auth.ts` | `useMe()` (eigenes Konto oder `null`), `useAuthChanged()` | +| `src/main.tsx` | Router, QueryClient | +| `src/Layout.tsx` | Header, Headerbar | +| `src/pages/*` | Startseite, Thread, Profil, Statistik | +| `src/components/*` | Feed, Beitragskarte, Votes, Repost, Login, Beitrag verfassen | + +Seitenrouten des Clients (`/`, `/e/:pid`, `/u/:username`, `/stats`) müssen +auch in `routes()` (main.go) stehen, sonst liefert ein direkter Aufruf 404. + +## Impressions + +Das Backend zählt Seitenaufrufe beim Ausliefern der `index.html`. Navigation +innerhalb der App lädt keine Seite mehr vom Server und wird deshalb nicht +gezählt -- nur der erste Aufruf bzw. ein Reload. diff --git a/stats.go b/stats.go index c96c20d..e31b668 100644 --- a/stats.go +++ b/stats.go @@ -305,10 +305,3 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error { }) return nil } - -// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All), -// damit trackImpression nur echte Seitenaufrufe von "/" zählt und nicht jedes -// Asset. -func serveIndex(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "web/index.html") -} diff --git a/user.go b/user.go index dcaf5d7..2d25f4a 100644 --- a/user.go +++ b/user.go @@ -63,12 +63,6 @@ func idFromUsername(username string) int64 { return id } -// serveUserPage liefert die statische Profilseite; das Frontend lädt die Daten -// dann über /u/{username}/info und /u/{username}/feed/{page}. -func serveUserPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "web/user.html") -} - func handleUserPage(w http.ResponseWriter, r *http.Request) error { username := chi.URLParam(r, "username") diff --git a/web/.well-known/security.txt b/web/.well-known/security.txt deleted file mode 100644 index e941bd3..0000000 --- a/web/.well-known/security.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Kontrollverlust — Responsible Disclosure -# Siehe auch Beitragsordnung §7 (Technische Regeln). -Contact: mailto:stress@bnd.wtf -Expires: 2027-06-15T00:00:00.000Z -Preferred-Languages: de, en -Canonical: https://kver.xyz/.well-known/security.txt diff --git a/web/css/app.css b/web/css/app.css deleted file mode 100644 index 8f661cb..0000000 --- a/web/css/app.css +++ /dev/null @@ -1,395 +0,0 @@ -/* Designsprache aus der alten Python-Version (verwaiste Linie bb6b966): - EB Garamond als Fließtext, Cheltenham Classic für die Überschrift, - Concrete-Textur als Hintergrund, weiße zentrierte Inhaltsspalte. */ - -/* font-display: swap -> Text wird sofort mit System-Serife gezeichnet und - wechselt zur Webschrift, sobald geladen (kein FOIT). Der Browser lädt nur - das erste passende src-Format, daher .ttf als truetype korrekt deklarieren. */ -@font-face { - font-family: "EB Garamond"; - src: url("/static/assets/EBGaramond-0.016/ttf/EBGaramond12-Regular.ttf") format("truetype"), - url("/static/assets/EBGaramond-0.016/otf/EBGaramond12-Regular.otf") format("opentype"); - font-weight: normal; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "EB Garamond"; - src: url("/static/assets/EBGaramond-0.016/ttf/EBGaramond12-Italic.ttf") format("truetype"), - url("/static/assets/EBGaramond-0.016/otf/EBGaramond12-Italic.otf") format("opentype"); - font-weight: normal; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: "Cheltenham Classic"; - src: url("/static/assets/cheltenham/CheltenhamClassic.ttf") format("truetype"), - url("/static/assets/cheltenham/CheltenhamClassic.otf") format("opentype"); - font-weight: normal; - font-style: normal; - font-display: swap; -} - -html { - --avatar-sm: 50px; /* Größe des Byline-Avatars; halbiert = Position des Thread-Strichs */ - --line-w: 3px; /* Dicke des Thread-Strichs */ - background-image: url("/static/assets/concrete/Concrete012_1024p_Color.jpg"); - font-family: "EB Garamond", serif; - font-optical-sizing: auto; -} - -body { - margin: 0; -} - -/* --- Kopf --- */ -header { - background-color: white; - margin: 0; - text-align: center; - padding: 0.5rem 0; -} - -/* Rechtliche Links über der Überschrift. */ -#legal { - margin-bottom: 0.3rem; -} - -#legal a { - font-size: 0.9rem; -} - -/* Klick auf die Überschrift führt zurück zum Feed. */ -header > a { - text-decoration: none; -} - -h1 { - font-family: "Cheltenham Classic", serif; - background-color: white; - display: inline-block; - margin: 0.2rem auto; -} - -h2 { - font-family: "Cheltenham Classic", serif; - background-color: white; - display: inline-block; - padding: 0 0.3rem; -} - -/* Kleine Statuszeile direkt unter dem Titel (Tage ohne Datenverlust + Kennzahlen). */ -.tagline { - margin: 0; - font-size: 1rem; - color: black; -} - -/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe, - gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen, - eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look). - aria-hidden im Markup: reine Dekoration, kein Vorlesen. */ -#sigils { - display: flex; - justify-content: center; - align-items: center; - gap: 1.2rem; - margin: 0.3rem 0; -} - -#sigils img { - height: 50px; - width: auto; -} - -#headerbar { - background-color: white; - text-align: center; -} - -#headerbar a { - background-color: white; -} - -/* --- Inhaltsspalte --- */ -main { - max-width: 600px; - margin: auto; -} - -main > section, -main > div, -main > h2, -main > p { - background-color: white; -} - -main > section { - padding: 0rem 0.3rem; -} - -main > h2 { - padding: 0.3rem 0.5rem; -} - -/* Rechtstexte (Impressum/Datenschutz/Beitragsordnung): weiße Spalte mit etwas - Innenabstand, damit der Fließtext nicht am Rand klebt. */ -.doc { - padding: 5px; -} - -span, a { - font-size: 1.2rem; -} - -a { - color: black; -} - -a:hover { - text-decoration: none; -} - -/* --- Buttons --- */ -a.button, button { - padding: 4px 8px; - border: 1px solid black; - margin: 2px 0; - display: inline-block; - color: black; - text-decoration: none; - background-color: white; - font-family: "EB Garamond", serif; - font-size: 1rem; - cursor: pointer; -} - -a.button:hover, button:hover { - text-decoration: underline; -} - -/* --- Einträge --- */ -.card { - position: relative; - cursor: pointer; - background-color: white; - margin-bottom: 0; - font-size: 1.2rem; - border: none; -} - -.card:hover { - background-color: #f7f7f5; -} - -.card .content { - padding: 0.5rem 0; - line-height: 1.5; - overflow-wrap: break-word; -} - -#focus .card { - margin: 0rem 0.3rem ; -} - -/* Beitragsbild (nicht der Byline-Avatar). */ -.card img:not(.avatar) { - display: block; - max-width: 300px; - margin: 0.5rem auto; -} - -/* Fokussierter Beitrag zeigt das Bild in voller Breite. */ -#focus .card img:not(.avatar) { - max-width: 100%; -} - -/* --- Avatare --- */ -img.avatar { - display: inline-block; - vertical-align: middle; - border-radius: 50%; - object-fit: cover; - background-color: #eee; - margin: 0; - max-width: none; -} - -img.avatar-sm { width: var(--avatar-sm); height: var(--avatar-sm); } -img.avatar-lg { width: 72px; height: 72px; } - -/* Byline mit kleinem Avatar neben Name + Zeitstempel. Einheitliche 1rem für die - ganze Meta-Zeile (Name, Datum, Repost-Cooldown), damit nichts größer rausfällt - als die Links -- der Klartext würde sonst die 1.2rem der Card erben. */ -.byline { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1rem; - /* Versuchsweise die Titel-Display-Schrift (wie h1/h2) in der Meta-Zeile. */ - font-family: "Cheltenham Classic", serif; -} - -/* Die globale Regel `span, a { font-size: 1.2rem }` trifft das Datum/Cooldown- - span direkt und macht es größer als den Username-Link (der über `.muted a` - auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */ -.byline span { font-size: 1rem; } - -/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */ -.profile-head { - display: flex; - align-items: center; - gap: 0.75rem; -} - -/* --- Thread-Strich --- - Der Strich ist ein eigenes Flex-Geschwister links neben dem Inhalt, nicht - eine Border am Inhalt selbst. align-self: stretch macht ihn exakt so hoch - wie der Inhalt (kein Über-/Unterstehen). Die Meta-Zeile (.byline) liegt - außerhalb des Wrappers, also volle Breite über dem Strich. - Standard (Feed/Fokus): Strich aus, Inhalt volle Breite. */ -.threadline-wrapper { display: flex; } -.threadline { display: none; align-self: stretch; } -.body { flex: 1; min-width: 0; margin-bottom: 1rem; } - -/* Vorfahren UND direkte Antworten: gleiche Einrückung, Meta-Zeile bleibt links. - Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der - Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum - Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem - rechten Avatarrand. Der .body füllt per flex:1 den Rest. */ -#ancestors .threadline, -#replies .threadline { - display: block; - /* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right - landet genau auf der Avatarmitte statt rechts daneben. */ - flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w)); - margin-right: calc(var(--avatar-sm) / 2); -} - -/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */ -#ancestors .threadline { border-right: var(--line-w) solid gray; } - -/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost - rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown - wandert in die Byline (siehe setupBump). */ -.interactions { - display: flex; - align-items: center; - justify-content: center; - gap: 1rem; - margin-top: 0.25rem; -} - -/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown - gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */ -.bump:disabled { - color: gray; - border-color: #ccc; - cursor: default; - text-decoration: none; -} - -.row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.muted { color: gray; } -.muted a { color: gray; font-size: 1rem; } -.ghost.selected { font-weight: bold; } -.msg { color: red; } - -p.warning { - background-color: #faa; - padding: 10px 15px; - font-weight: bold; -} - -/* --- Formulare --- */ -form { - background-color: white; - margin: auto; - padding: 0.5rem 0; -} - -form input, -form textarea { - font-family: "EB Garamond", serif; - width: 100%; - box-sizing: border-box; - margin: 4px 0; -} - -form textarea { - padding: 5px; - border: 0; - outline: 1px solid black; - resize: vertical; -} - -form input[type="file"] { - outline: none; -} - -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; - text-align: right; - 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 deleted file mode 100644 index 2c24446..0000000 --- a/web/entry.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - Kontrollverlust - - - -
- -

Kontrollverlust

- -
- -
-
-
- - - - -
-
- - - - - diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 4879c97..0000000 --- a/web/index.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - Kontrollverlust - - - -
- -

Kontrollverlust

-

-
-
- -
- - - -
-
-

Login

- - - - -

Noch kein Konto? Registrieren

-
- - - -

-
- - - - - -
-
-
- - - - - diff --git a/web/js/app.js b/web/js/app.js deleted file mode 100644 index 45ba8ae..0000000 --- a/web/js/app.js +++ /dev/null @@ -1,122 +0,0 @@ -"use strict"; - -// --- Headerbar (eingeloggt?) --- -let loggedIn = false; - -async function refreshHeader() { - const me = await api.get("/user/info"); - loggedIn = me.ok; - - document.getElementById("auth-box").hidden = loggedIn; - document.getElementById("compose").hidden = !loggedIn; - - const bar = document.getElementById("headerbar"); - if (loggedIn) { - const user = encodeURIComponent(me.data.username); - bar.innerHTML = `mein Profil `; - document.getElementById("logout-btn").addEventListener("click", async () => { - // POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF). - await api.post("/auth/logout", new FormData()); - await refreshHeader(); - }); - } else { - bar.innerHTML = ""; - showAuth("login"); - } -} - -// --- Login / Registrierung --- -const loginForm = document.getElementById("login-form"); -const registerForm = document.getElementById("register-form"); -const authMsg = document.getElementById("auth-msg"); - -function showAuth(which) { - loginForm.hidden = which !== "login"; - registerForm.hidden = which !== "register"; - authMsg.textContent = ""; -} - -document.getElementById("show-register").addEventListener("click", (e) => { - e.preventDefault(); - showAuth("register"); -}); -document.getElementById("show-login").addEventListener("click", (e) => { - e.preventDefault(); - showAuth("login"); -}); - -loginForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const res = await api.post("/auth/login", new FormData(e.target)); - if (res.ok) { - await refreshHeader(); - } else { - authMsg.textContent = errMsg(res.data, "Login fehlgeschlagen."); - } -}); - -registerForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const data = new FormData(e.target); - const res = await api.post("/auth/newuser", data); - if (!res.ok) { - // newuser meldet alle Feldfehler auf einmal (error.fields). - authMsg.textContent = errMsg(res.data, "Registrierung fehlgeschlagen."); - return; - } - // Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist. - const login = new FormData(); - login.append("user", data.get("user")); - login.append("pass", data.get("pass1")); - login.append("timeout", "86400"); - await api.post("/auth/login", login); - await refreshHeader(); -}); - -// --- Feed --- -const feed = createFeed( - document.getElementById("feed"), - document.getElementById("sentinel"), - (page) => `/entry/feed/${page}` -); - -// --- Neuer Beitrag --- -document.getElementById("entry-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const res = await api.post("/entry/create", new FormData(e.target)); - const msg = document.getElementById("entry-msg"); - if (res.ok) { - msg.textContent = ""; - document.getElementById("paste-msg").textContent = ""; - e.target.reset(); - feed.reset(); - } else { - msg.textContent = errMsg(res.data, "Beitrag fehlgeschlagen."); - } -}); - -// --- Bild aus der Zwischenablage einfügen --- -setupClipboardPaste( - document.getElementById("entry-form"), - document.getElementById("paste-msg") -); - -// --- Statuszeile unter dem Titel --- -// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten -// Version). -const dataLossStart = new Date("2025-12-13T12:44:00").getTime(); - -function plural(n, one, many) { - return `${n} ${n === 1 ? one : many}`; -} - -function renderStats() { - const el = document.getElementById("stats"); - if (!el) return; - const days = Math.floor((Date.now() - dataLossStart) / 86400000); - el.textContent = `Ohne Datenverlust seit ${plural(days, "Tag", "Tagen")}`; -} - -// --- Start --- -refreshHeader(); -renderStats(); diff --git a/web/js/entry.js b/web/js/entry.js deleted file mode 100644 index 77f729d..0000000 --- a/web/js/entry.js +++ /dev/null @@ -1,97 +0,0 @@ -"use strict"; - -// pid aus dem Pfad /e/. -const pid = location.pathname.split("/")[2] || ""; - -const replyForm = document.getElementById("reply-form"); -setupClipboardPaste(replyForm, document.getElementById("reply-paste-msg")); - -replyForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const fd = new FormData(e.target); - fd.append("reply_to", pid); - const res = await api.post("/entry/create", fd); - if (res.ok) { - e.target.reset(); - document.getElementById("reply-msg").textContent = ""; - document.getElementById("reply-paste-msg").textContent = ""; - load(); - } else { - document.getElementById("reply-msg").textContent = - errMsg(res.data, "Antwort fehlgeschlagen."); - } -}); - -async function load() { - const res = await api.get(`/entry/${pid}/thread`); - - const focus = document.getElementById("focus"); - if (!res.ok) { - focus.textContent = errMsg(res.data, "Beitrag konnte nicht geladen werden."); - return; - } - const data = res.data; - - // Ahnenkette (Root zuerst), dann der fokussierte Beitrag. - const ancestors = document.getElementById("ancestors"); - ancestors.innerHTML = ""; - data.ancestors.forEach((a) => ancestors.appendChild(renderEntry(a))); - - focus.innerHTML = ""; - focus.appendChild(renderEntry(data.entry)); - - // Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen. - // 401 heißt nicht angemeldet -> me bleibt leer. - const meRes = await api.get("/user/info"); - const me = meRes.ok ? meRes.data : {}; - document.getElementById("reply-box").hidden = !me.uid; - if (me.uid === data.entry.uid && !data.entry.deleted) { - const editBtn = document.createElement("button"); - editBtn.textContent = "Bearbeiten"; - editBtn.addEventListener("click", () => { - editBtn.disabled = true; - const form = document.createElement("form"); - form.innerHTML = ` - - - -

`; - form.querySelector("textarea").value = data.entry.content; - form.addEventListener("submit", async (ev) => { - ev.preventDefault(); - const res = await api.post(`/entry/${pid}/edit`, new FormData(form)); - if (res.ok) load(); - else form.querySelector(".msg").textContent = - errMsg(res.data, "Bearbeiten fehlgeschlagen."); - }); - form.querySelector(".cancel").addEventListener("click", load); - focus.appendChild(form); - }); - - const delBtn = document.createElement("button"); - delBtn.textContent = "Löschen"; - delBtn.addEventListener("click", async () => { - if (!confirm("Beitrag wirklich löschen?")) return; - const res = await api.post(`/entry/${pid}/delete`, new FormData()); - if (res.ok) load(); - else alert(errMsg(res.data, "Löschen fehlgeschlagen.")); - }); - - focus.appendChild(editBtn); - focus.appendChild(delBtn); - } - - // Direkte Antworten. - const replies = document.getElementById("replies"); - replies.innerHTML = ""; - if (!data.replies.length) { - const p = document.createElement("p"); - p.className = "muted"; - p.textContent = "Noch keine Antworten."; - replies.appendChild(p); - } else { - data.replies.forEach((e) => replies.appendChild(renderEntry(e))); - } -} - -load(); diff --git a/web/js/feed.js b/web/js/feed.js deleted file mode 100644 index c3db940..0000000 --- a/web/js/feed.js +++ /dev/null @@ -1,289 +0,0 @@ -"use strict"; - -// Die API liegt unter /api und antwortet ausschließlich mit JSON. Ob ein Aufruf -// geklappt hat, sagt der HTTP-Status (ok/status); data ist bei Fehlern das -// Fehlerobjekt {"error": {...}}. Pfade werden ohne /api übergeben. -const JSON_HEADERS = { Accept: "application/json" }; - -async function apiResult(r) { - return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }; -} - -const api = { - async get(path) { - return apiResult(await fetch(`/api${path}`, { - credentials: "same-origin", - headers: JSON_HEADERS, - })); - }, - async post(path, formData) { - return apiResult(await fetch(`/api${path}`, { - method: "POST", - body: formData, - credentials: "same-origin", - headers: JSON_HEADERS, - })); - }, -}; - -// errMsg zieht die Nutzermeldung aus dem Fehlerformat der API: -// {"error": {"code", "message", "field", "fields"}}. Liegen mehrere -// Feldfehler vor (Registrierung), werden sie zu einem Satz zusammengezogen. -// Der code ist bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht -// für Menschen. -function errMsg(data, fallback) { - const e = data && data.error; - if (!e) return fallback; - const fields = e.fields ? Object.values(e.fields) : []; - if (fields.length) return fields.join(" "); - return e.message || fallback; -} - -// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML- -// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt -// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde. -function escapeHtml(s) { - return String(s ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar. -function avatarUrl(path) { - return path ? `/${path}` : "/static/assets/no_profile_pic.jpg"; -} - -function linkify(s) { - const escaped = escapeHtml(s); - const url = /https?:\/\/[^\s<]+/g; - return escaped - .replace(url, (m) => `${m}`) - .replace(/\n/g, "
"); -} - -// setupClipboardPaste erlaubt Strg+V eines Bildes aus der Zwischenablage direkt -// im Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich -// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir -// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn -// wir wirklich ein Bild gegriffen haben. msgEl (optional) zeigt den Hinweis. -function setupClipboardPaste(form, msgEl) { - form.addEventListener("paste", (e) => { - const item = [...(e.clipboardData?.items ?? [])].find( - (it) => it.kind === "file" && it.type.startsWith("image/") - ); - if (!item) return; - - const original = item.getAsFile(); - if (!original) return; - e.preventDefault(); - - // Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu - // verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png. - const ext = (original.type.split("/")[1] || "png").toLowerCase(); - const file = new File([original], `zwischenablage.${ext}`, { type: original.type }); - - const input = form.querySelector("input[name='file']"); - const dt = new DataTransfer(); - dt.items.add(file); - input.files = dt.files; - - if (msgEl) msgEl.textContent = "Bild aus Zwischenablage übernommen."; - }); -} - -function renderEntry(e) { - const el = document.createElement("article"); - el.className = "card"; - - const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); - - // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. - if (e.deleted) { - el.innerHTML = ` - -
-
-
-
[deleted]
-
-
- `; - el.addEventListener("click", (ev) => { - if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; - }); - return el; - } - - const user = encodeURIComponent(e.username); - let media = ""; - if (e.filepath) { - media = ``; - } - - // Antwort- und Repost-Zahl wandern in die Buttons unten; in der Byline bleibt - // nur Name, Zeit und (im Cooldown) der Hinweis bis zum nächsten Repost. - el.innerHTML = ` - -
-
-
-
${linkify(e.content)}
- ${media} -
- Antworten (${e.reply_count}) -
- - – / – - -
- -
-
-
- `; - setupVoting(el, e.pid); - setupBump(el, e); - el.addEventListener("click", (ev) => { - if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; - }); - return el; -} - -function setupVoting(el, pid) { - const box = el.querySelector(".row"); - const counts = box.querySelector("span"); - const buttons = box.querySelectorAll(".ghost"); - - function render(res) { - if (!res.ok) { - counts.textContent = errMsg(res.data, "Fehler"); - return; - } - const state = res.data; - counts.textContent = `${state.left} / ${state.right}`; - buttons.forEach((b) => - b.classList.toggle("selected", b.dataset.mode === state.selected) - ); - } - - buttons.forEach((b) => - b.addEventListener("click", async () => { - const fd = new FormData(); - fd.append("mode", b.dataset.mode); - render(await api.post(`/entry/${pid}/vote`, fd)); - }) - ); - - api.get(`/entry/${pid}/votes`).then(render); -} - -// setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear -// (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine -// Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den -// Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks. -function setupBump(el, e) { - const btn = el.querySelector(".bump"); - if (!btn) return; - const cooldown = el.querySelector(".cooldown"); - - // sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten. - const nowSec = () => Math.floor(Date.now() / 1000); - - // lock sperrt den Button; die Restzeit steht jetzt oben in der Byline, auf - // Stunden aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist). - function lock(retryAfter) { - btn.disabled = true; - const hours = Math.max(1, Math.ceil(retryAfter / 3600)); - if (cooldown) cooldown.textContent = ` · Repost in ${hours}h`; - } - - const remaining = e.last_bump + e.bump_count * 86400 - nowSec(); - if (e.bump_count > 0 && remaining > 0) lock(remaining); - - btn.addEventListener("click", async () => { - const res = await api.post(`/entry/${e.pid}/bump`, new FormData()); - if (res.ok) { - btn.textContent = `Repost (${res.data.bump_count})`; - lock(res.data.retry_after || 86400); - } else if (res.status === 429) { - lock(res.data.retry_after || 86400); - } else { - // 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen. - btn.textContent = errMsg(res.data, "Fehler"); - } - }); -} - -// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über -// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden. -function createFeed(feedEl, sentinelEl, urlFor) { - let page = 0; - let loading = false; - let done = false; - - async function loadMore() { - if (loading || done) return; - loading = true; - - const res = await api.get(urlFor(page)); - if (!res.ok) { - // Fehler nicht als "Ende" verbuchen: Meldung zeigen und weiteres - // Nachladen stoppen, statt in einer Schleife neu anzufragen. - done = true; - const err = document.createElement("p"); - err.className = "msg"; - err.textContent = errMsg(res.data, "Feed konnte nicht geladen werden."); - feedEl.appendChild(err); - loading = false; - return; - } - const entries = res.data; - if (!entries.length) { - done = true; - const end = document.createElement("p"); - end.className = "muted"; - end.textContent = "YOU REACHED THE END!"; - feedEl.appendChild(end); - } else { - entries.forEach((e) => feedEl.appendChild(renderEntry(e))); - page++; - } - loading = false; - - // Der IntersectionObserver feuert nur bei Zustandswechseln. Füllt die - // gerade geladene Seite den Viewport nicht über den Sentinel hinaus, gäbe - // es keinen Wechsel und nie ein Nachladen -> hier aktiv weiterladen, bis - // der Sentinel aus dem Sichtbereich wandert oder das Ende erreicht ist. - if (!done && sentinelVisible()) loadMore(); - } - - function sentinelVisible() { - return sentinelEl.getBoundingClientRect().top <= window.innerHeight; - } - - function reset() { - page = 0; - done = false; - feedEl.innerHTML = ""; - loadMore(); - } - - // rootMargin lädt schon kurz vor Erreichen des Sentinels nach (flüssiger und - // robust gegen den Fall eines 0-hohen Sentinels exakt am Dokumentende). - const observer = new IntersectionObserver( - (items) => { - if (items.some((i) => i.isIntersecting)) loadMore(); - }, - { rootMargin: "300px" } - ); - observer.observe(sentinelEl); - - loadMore(); - return { reset }; -} diff --git a/web/js/stats.js b/web/js/stats.js deleted file mode 100644 index 630b5a9..0000000 --- a/web/js/stats.js +++ /dev/null @@ -1,116 +0,0 @@ -"use strict"; - -// Statistik-Seite: holt die Aggregate von /api/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); - // 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)); - 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.style.width = `${valWidth}ch`; - val.textContent = it.value; - - row.append(label, track, val); - container.append(row); - } -} - -async function render() { - const res = await api.get("/stats/detail"); - if (!res.ok) { - const p = document.createElement("p"); - p.className = "msg"; - p.textContent = errMsg(res.data, "Statistik konnte nicht geladen werden."); - document.getElementById("stat-overview").append(p); - return; - } - const s = res.data; - - // --- 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(); - const dayAxis = (day) => day.slice(5); // "MM-DD" reicht auf der Achse - renderBars( - document.getElementById("stat-daily"), - daily.map((d) => ({ label: d.day, value: d.impressions })), - dayAxis, - ); - - // --- Verschiedene Netze pro Tag --- - renderBars( - document.getElementById("stat-networks"), - daily.map((d) => ({ label: d.day, value: d.visitors })), - dayAxis, - ); -} - -render(); diff --git a/web/js/user.js b/web/js/user.js deleted file mode 100644 index 7fea2ec..0000000 --- a/web/js/user.js +++ /dev/null @@ -1,69 +0,0 @@ -"use strict"; - -// Nutzername aus dem Pfad /u/. -const username = decodeURIComponent(location.pathname.split("/")[2] || ""); - -document.getElementById("rename-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const res = await api.post("/user/rename", new FormData(e.target)); - if (res.ok) { - location.href = `/u/${encodeURIComponent(res.data.username)}`; - } else { - document.getElementById("rename-msg").textContent = - errMsg(res.data, "Umbenennen fehlgeschlagen."); - } -}); - -document.getElementById("avatar-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const res = await api.post("/user/avatar", new FormData(e.target)); - if (res.ok) { - // Cache umgehen, damit das neue Bild sofort erscheint. - document.getElementById("profile-avatar").src = `${avatarUrl(res.data.avatar)}?t=${Date.now()}`; - e.target.reset(); - document.getElementById("avatar-msg").textContent = ""; - } else { - document.getElementById("avatar-msg").textContent = - errMsg(res.data, "Hochladen fehlgeschlagen."); - } -}); - -document.getElementById("delete-form").addEventListener("submit", async (e) => { - e.preventDefault(); - if (!confirm("Konto wirklich unwiderruflich löschen?")) return; - const res = await api.post("/user/delete", new FormData(e.target)); - if (res.ok) { - location.href = "/"; - } else { - document.getElementById("delete-msg").textContent = - errMsg(res.data, "Löschen fehlgeschlagen."); - } -}); - -async function init() { - const res = await api.get(`/u/${encodeURIComponent(username)}/info`); - if (!res.ok) { - document.getElementById("profile-name").textContent = - errMsg(res.data, "Profil konnte nicht geladen werden."); - return; - } - const info = res.data; - - document.getElementById("profile-name").textContent = info.username; - document.getElementById("profile-avatar").src = avatarUrl(info.avatar); - const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE"); - document.getElementById("profile-info").textContent = `Mitglied seit ${since}`; - - // Eigene Seite? -> Einstellungen einblenden. - const me = await api.get("/user/info"); - const isOwner = me.ok && me.data.username === info.username; - document.getElementById("settings").hidden = !isOwner; - - createFeed( - document.getElementById("feed"), - document.getElementById("sentinel"), - (page) => `/u/${encodeURIComponent(username)}/feed/${page}` - ); -} - -init(); diff --git a/web/stats.html b/web/stats.html deleted file mode 100644 index 897529f..0000000 --- a/web/stats.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - Statistik – Kontrollverlust - - - -
- -

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

-
- -

Verschiedene Netze pro Tag

-
-
-
- - - - - diff --git a/web/user.html b/web/user.html deleted file mode 100644 index 0a1ce19..0000000 --- a/web/user.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - Kontrollverlust - - - -
- -

Kontrollverlust

- -
- -
-
-
- -
-

-

-
-
- - - - -
-
-
-
- - - - -