React-Frontend ausliefern, altes Vanilla-Frontend entfernt
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
728ac9b45e
commit
9cac33d167
+1
-1
@@ -16,4 +16,4 @@ migrate.sh
|
|||||||
Containerfile
|
Containerfile
|
||||||
.containerignore
|
.containerignore
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
web/
|
||||||
|
|||||||
+1
-1
@@ -11,4 +11,4 @@ __pycache__/
|
|||||||
static/media/
|
static/media/
|
||||||
prod_backup/
|
prod_backup/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
/web/
|
||||||
|
|||||||
+11
-1
@@ -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
|
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
|
||||||
# einmaligen Import sind pures Go).
|
# einmaligen Import sind pures Go).
|
||||||
FROM docker.io/library/golang:1.26-alpine AS build
|
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
|
RUN adduser -D -u 1000 kver
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /kver /usr/local/bin/kver
|
COPY --from=build /kver /usr/local/bin/kver
|
||||||
COPY web/ web/
|
COPY --from=frontend /src/web web/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
|
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
|
||||||
# static/media: Uploads. Beide als Volume persistieren.
|
# static/media: Uploads. Beide als Volume persistieren.
|
||||||
|
|||||||
@@ -150,12 +150,6 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
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 (
|
var (
|
||||||
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
||||||
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
||||||
|
|||||||
+35
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ const proxy = { target: backend, changeOrigin: false };
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": proxy,
|
"/api": proxy,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
|
// KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per
|
||||||
// relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
|
// Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert --
|
||||||
// nicht mehr an das Start-CWD gebunden.
|
// die App ist damit nicht mehr an das Start-CWD gebunden.
|
||||||
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
||||||
if err := os.Chdir(dir); err != nil {
|
if err := os.Chdir(dir); err != nil {
|
||||||
log.Fatalf("KVER_DIR: %v", err)
|
log.Fatalf("KVER_DIR: %v", err)
|
||||||
@@ -102,8 +102,8 @@ func envOr(key, fallback string) string {
|
|||||||
// Tests identisch verwendet.
|
// Tests identisch verwendet.
|
||||||
//
|
//
|
||||||
// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405,
|
// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405,
|
||||||
// Rate-Limit und Panics. Der Rest liefert die HTML-Seiten und Dateien des
|
// Rate-Limit und Panics. Der Rest liefert das Frontend (frontend.go) und
|
||||||
// Frontends.
|
// statische Dateien.
|
||||||
func routes() http.Handler {
|
func routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
|
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
|
||||||
@@ -166,22 +166,27 @@ func routes() http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- HTML-Seiten ---
|
// --- Frontend (React) ---
|
||||||
// Mit Impression-Tracking (anonymisiert, siehe stats.go). Nur echte
|
// Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/
|
||||||
// Seitenaufrufe, nicht API/Assets.
|
// main.tsx) und liefern alle dieselbe index.html. Impression-Tracking
|
||||||
r.With(trackImpression).Get("/", serveIndex)
|
// (anonymisiert, siehe stats.go) nur auf echten Seitenaufrufen, nicht auf
|
||||||
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
// API/Assets; die Statistik-Seite zählt wie bisher nicht mit.
|
||||||
r.With(trackImpression).Get("/u/{username}", serveUserPage)
|
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
|
// Hochgeladene Medien + statische Dateien (Schriften, Rechtstexte).
|
||||||
// secHeaders) verhindert, dass der Browser eine durchgereichte (nicht
|
// nosniff (global via secHeaders) verhindert, dass der Browser eine
|
||||||
// re-kodierte) Datei als HTML/JS interpretiert -> schließt
|
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
|
||||||
// Polyglot/Stored-XSS über Bild-Uploads.
|
// schließt Polyglot/Stored-XSS über Bild-Uploads.
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -27,8 +27,11 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
|
|||||||
podman build -t kver .
|
podman build -t kver .
|
||||||
```
|
```
|
||||||
|
|
||||||
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit-
|
Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer
|
||||||
Image ist Alpine mit unprivilegiertem Nutzer.
|
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:
|
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
|
||||||
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
|
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -305,10 +305,3 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
|||||||
})
|
})
|
||||||
return nil
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ func idFromUsername(username string) int64 {
|
|||||||
return id
|
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 {
|
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
||||||
username := chi.URLParam(r, "username")
|
username := chi.URLParam(r, "username")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
-395
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section id="ancestors"></section>
|
|
||||||
<div id="focus"></div>
|
|
||||||
|
|
||||||
<!-- Nur für eingeloggte Nutzer -->
|
|
||||||
<section id="reply-box" hidden>
|
|
||||||
<form id="reply-form">
|
|
||||||
<textarea name="content" rows="3" maxlength="1000" placeholder="Antworten…"></textarea>
|
|
||||||
<p class="muted" id="reply-paste-msg"></p>
|
|
||||||
<input name="file" type="file" accept="image/*">
|
|
||||||
<button type="submit">Antworten</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="reply-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="replies"></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/entry.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<p id="stats" class="tagline"></p>
|
|
||||||
<div id="headerbar"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<noscript>
|
|
||||||
<p class="msg">Diese Seite braucht JavaScript.</p>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<!-- Login / Registrierung -->
|
|
||||||
<section id="auth-box">
|
|
||||||
<form id="login-form">
|
|
||||||
<h2>Login</h2>
|
|
||||||
<input name="user" placeholder="Nutzername" autocomplete="username">
|
|
||||||
<input name="pass" type="password" placeholder="Passwort" autocomplete="current-password">
|
|
||||||
<input name="timeout" type="hidden" value="86400">
|
|
||||||
<button type="submit">Einloggen</button>
|
|
||||||
<p class="muted">Noch kein Konto? <a href="#" id="show-register">Registrieren</a></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="register-form" hidden>
|
|
||||||
<h2>Registrieren</h2>
|
|
||||||
<input name="user" placeholder="Nutzername" autocomplete="username">
|
|
||||||
<input name="pass1" type="password" placeholder="Passwort" autocomplete="new-password">
|
|
||||||
<input name="pass2" type="password" placeholder="Passwort wiederholen" autocomplete="new-password">
|
|
||||||
<button type="submit">Konto erstellen</button>
|
|
||||||
<p class="muted">Schon registriert? <a href="#" id="show-login">Zum Login</a></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="msg" id="auth-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Neuer Beitrag -->
|
|
||||||
<section id="compose" hidden>
|
|
||||||
<form id="entry-form">
|
|
||||||
<textarea name="content" rows="4" maxlength="1000" placeholder="Was gibt's Neues?"></textarea>
|
|
||||||
<p class="muted" id="paste-msg"></p>
|
|
||||||
<input name="file" type="file" accept="image/*">
|
|
||||||
<button type="submit">Eintrag erstellen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="entry-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Feed -->
|
|
||||||
<section id="feed"></section>
|
|
||||||
<div id="sentinel"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-122
@@ -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 = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
|
|
||||||
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();
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// pid aus dem Pfad /e/<pid>.
|
|
||||||
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 = `
|
|
||||||
<textarea name="content" rows="4" maxlength="1000"></textarea>
|
|
||||||
<button type="submit">Speichern</button>
|
|
||||||
<button type="button" class="cancel">Abbrechen</button>
|
|
||||||
<p class="msg"></p>`;
|
|
||||||
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();
|
|
||||||
-289
@@ -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, """)
|
|
||||||
.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) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
|
|
||||||
.replace(/\n/g, "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = `
|
|
||||||
<div class="muted byline"><span>[deleted] · ${when}</span></div>
|
|
||||||
<div class="threadline-wrapper">
|
|
||||||
<div class="threadline"></div>
|
|
||||||
<div class="body">
|
|
||||||
<div class="content">[deleted]</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = `
|
|
||||||
<div class="muted byline">
|
|
||||||
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
|
|
||||||
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
|
|
||||||
</div>
|
|
||||||
<div class="threadline-wrapper">
|
|
||||||
<div class="threadline"></div>
|
|
||||||
<div class="body">
|
|
||||||
<div class="content">${linkify(e.content)}</div>
|
|
||||||
${media}
|
|
||||||
<div class="interactions">
|
|
||||||
<a class="button reply" href="/e/${e.pid}">Antworten (${e.reply_count})</a>
|
|
||||||
<div class="row">
|
|
||||||
<button class="ghost" data-mode="left">Links</button>
|
|
||||||
<span class="muted">– / –</span>
|
|
||||||
<button class="ghost" data-mode="right">Rechts</button>
|
|
||||||
</div>
|
|
||||||
<button class="bump" type="button">Repost (${e.bump_count})</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
-116
@@ -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();
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Nutzername aus dem Pfad /u/<name>.
|
|
||||||
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();
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Statistik – Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<h2>Statistik</h2>
|
|
||||||
|
|
||||||
<h3>Auf einen Blick</h3>
|
|
||||||
<div id="stat-overview" class="statgrid"></div>
|
|
||||||
|
|
||||||
<h3>Reichweite</h3>
|
|
||||||
<p class="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" class="statgrid"></div>
|
|
||||||
|
|
||||||
<h3>Netze nach Betreiber</h3>
|
|
||||||
<p class="muted">
|
|
||||||
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
|
|
||||||
</p>
|
|
||||||
<div id="stat-asns" class="bars"></div>
|
|
||||||
|
|
||||||
<h3>Aufrufe pro Tag</h3>
|
|
||||||
<div id="stat-daily" class="bars"></div>
|
|
||||||
|
|
||||||
<h3>Verschiedene Netze pro Tag</h3>
|
|
||||||
<div id="stat-networks" class="bars"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/stats.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section id="profile">
|
|
||||||
<div class="profile-head">
|
|
||||||
<img id="profile-avatar" class="avatar avatar-lg" src="/static/assets/no_profile_pic.jpg" alt="">
|
|
||||||
<div>
|
|
||||||
<h2 id="profile-name"></h2>
|
|
||||||
<p id="profile-info" class="muted"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nur für den Eigentümer sichtbar -->
|
|
||||||
<div id="settings" hidden>
|
|
||||||
<h2>Einstellungen</h2>
|
|
||||||
<details>
|
|
||||||
<summary>Profilbild ändern</summary>
|
|
||||||
<form id="avatar-form">
|
|
||||||
<input name="avatar" type="file" accept="image/*">
|
|
||||||
<button type="submit">Bild hochladen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="avatar-msg"></p>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Name ändern</summary>
|
|
||||||
<form id="rename-form">
|
|
||||||
<input name="user" type="text" placeholder="Neuer Nutzername" autocomplete="username">
|
|
||||||
<button type="submit">Namen ändern</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="rename-msg"></p>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Konto löschen</summary>
|
|
||||||
<form id="delete-form">
|
|
||||||
<p class="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">Konto endgültig löschen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="delete-msg"></p>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="feed"></div>
|
|
||||||
<div id="sentinel"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/user.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user