React-Frontend: Grundgerüst (Vite, TypeScript, Router, TanStack Query)

- frontend/ mit Vite 8, React 19, React Router 8, TanStack Query 5, TS 7
- api.ts: Fehler allein über HTTP-Status (ApiError), errMsg wie bisher
- types.ts: API-Antworten laut notes/api.md
- Layout mit unverändertem Header-Markup, app.css als Kopie
- Dev-Proxy für /api und /static aufs Go-Backend
- Containerfile: HEALTHCHECK auf /api/stats

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:18:56 +02:00
co-authored by Claude Opus 5.5
parent 030a5bd943
commit 37279ac90d
18 changed files with 2091 additions and 1 deletions
+2
View File
@@ -15,3 +15,5 @@ static/media/
migrate.sh migrate.sh
Containerfile Containerfile
.containerignore .containerignore
frontend/node_modules/
frontend/dist/
+2
View File
@@ -10,3 +10,5 @@ original-kver.db
__pycache__/ __pycache__/
static/media/ static/media/
prod_backup/ prod_backup/
frontend/node_modules/
frontend/dist/
+1 -1
View File
@@ -22,5 +22,5 @@ ENV KVER_ADDR=:8080
EXPOSE 8080 EXPOSE 8080
VOLUME ["/app/data", "/app/static/media"] VOLUME ["/app/data", "/app/static/media"]
HEALTHCHECK --interval=30s --timeout=3s \ HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO /dev/null http://localhost:8080/entry/feed/0 || exit 1 CMD wget -qO /dev/null http://localhost:8080/api/stats || exit 1
ENTRYPOINT ["/usr/local/bin/kver"] ENTRYPOINT ["/usr/local/bin/kver"]
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrollverlust</title>
</head>
<body>
<noscript>
<p class="msg">Diese Seite braucht JavaScript.</p>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1373
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "kver-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc"
},
"dependencies": {
"@tanstack/react-query": "^5.104.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0"
},
"devDependencies": {
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.1"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Link, Outlet, useLocation } from "react-router";
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version).
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
function plural(n: number, one: string, many: string) {
return `${n} ${n === 1 ? one : many}`;
}
// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten
// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat
// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const isHome = useLocation().pathname === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
return (
<>
<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> -{" "}
<Link to="/stats">Statistik</Link>
</nav>
<Link to="/"><h1>Kontrollverlust</h1></Link>
{isHome ? (
<>
<p id="stats" className="tagline">
Ohne Datenverlust seit {plural(days, "Tag", "Tagen")}
</p>
<div id="headerbar"></div>
</>
) : (
<div id="headerbar"><Link className="button" to="/">zurück zum Feed</Link></div>
)}
</header>
<main>
<Outlet />
</main>
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { ApiErrorBody } from "./types";
// ApiError ist jeder Nicht-2xx-Status der API. Ob ein Aufruf geklappt hat,
// entscheidet allein der HTTP-Status; der Body liefert Code und Meldung.
// extra hält Zusatzfelder neben "error" (z. B. retry_after beim Repost).
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly field?: string;
readonly fields?: Record<string, string>;
readonly extra: Record<string, unknown>;
constructor(status: number, body: ApiErrorBody | undefined, extra: Record<string, unknown>) {
super(body?.message ?? `HTTP ${status}`);
this.status = status;
this.code = body?.code ?? "unknown";
this.field = body?.field;
this.fields = body?.fields;
this.extra = extra;
}
}
async function request<T>(method: "GET" | "POST", path: string, body?: FormData): Promise<T> {
const r = await fetch(`/api${path}`, {
method,
body,
credentials: "same-origin",
headers: { Accept: "application/json" },
});
// Auch Fehler kommen als JSON; ein kaputter Body (Proxy-Fehlerseite o. Ä.)
// darf die Auswertung des Status nicht verhindern.
const data: unknown = await r.json().catch(() => undefined);
if (!r.ok) {
const { error, ...extra } = (data ?? {}) as { error?: ApiErrorBody } & Record<string, unknown>;
throw new ApiError(r.status, error, extra);
}
return data as T;
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body: FormData = new FormData()) => request<T>("POST", path, body),
};
// errMsg macht aus einem Fehler den Anzeigetext. 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.
export function errMsg(err: unknown, fallback: string): string {
if (!(err instanceof ApiError)) return fallback;
const fields = err.fields ? Object.values(err.fields) : [];
if (fields.length) return fields.join(" ");
return err.message || fallback;
}
+395
View File
@@ -0,0 +1,395 @@
/* 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;
}
+44
View File
@@ -0,0 +1,44 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createBrowserRouter } from "react-router";
import { ApiError } from "./api";
import { Layout } from "./Layout";
import { FeedPage } from "./pages/FeedPage";
import { EntryPage } from "./pages/EntryPage";
import { UserPage } from "./pages/UserPage";
import { StatsPage } from "./pages/StatsPage";
import "./app.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 4xx ist eine Antwort, kein Aussetzer -- erneut fragen ändert nichts.
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
// Beim Zurückwechseln in den Tab nicht den ganzen Feed neu laden; die
// Daten werden gezielt nach eigenen Aktionen invalidiert.
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ path: "/", element: <FeedPage /> },
{ path: "/e/:pid", element: <EntryPage /> },
{ path: "/u/:username", element: <UserPage /> },
{ path: "/stats", element: <StatsPage /> },
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
+4
View File
@@ -0,0 +1,4 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
export function EntryPage() {
return null;
}
+4
View File
@@ -0,0 +1,4 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
export function FeedPage() {
return null;
}
+4
View File
@@ -0,0 +1,4 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
export function StatsPage() {
return null;
}
+4
View File
@@ -0,0 +1,4 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
export function UserPage() {
return null;
}
+86
View File
@@ -0,0 +1,86 @@
// Typen der JSON-API; Referenz ist notes/api.md. Zeitstempel sind
// Unix-Sekunden.
export interface Entry {
pid: number;
uid: number;
created_at: number;
content: string;
filepath: string;
reply_to: number;
reply_count: number;
last_activity: number;
deleted: number;
bump_count: number;
last_bump: number;
username: string;
avatar: string;
}
export interface Thread {
entry: Entry;
ancestors: Entry[];
replies: Entry[];
}
export type VoteMode = "left" | "right";
export interface VoteTally {
pid: number;
left: number;
right: number;
selected: VoteMode | "none";
}
export interface BumpResult {
pid: number;
bump_count: number;
last_bump: number;
last_activity: number;
retry_after: number;
}
export interface CreatedEntry {
pid: number;
content: string;
filepath: string;
reply_to: number;
}
// GET /api/user/info -- das eigene Konto.
export interface Me {
uid: number;
username: string;
created_at: number;
last_login: number;
avatar: string;
}
// GET /api/u/{username}/info -- öffentliches Profil.
export interface Profile {
uid: number;
username: string;
created_at: number;
avatar: string;
}
export interface StatsDetail {
users: number;
entries: number;
toplevel: number;
replies: number;
votes: { left: number; right: number };
impressions_total: number;
visitors_total: number;
daily: { day: string; impressions: number; visitors: number }[];
top_asns: { asn: string; hits: number; networks: number }[];
}
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern
// daneben (Repost-Cooldown: retry_after, bump_count).
export interface ApiErrorBody {
code: string;
message: string;
field?: string;
fields?: Record<string, string>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Im Dev-Betrieb läuft das Go-Backend separat (siehe dev.env, Port 8081).
// Vite reicht /api und /static dorthin durch -- für den Browser bleibt alles
// eine Origin, Session-Cookie und Origin-Check funktionieren unverändert.
const backend = process.env.KVER_BACKEND ?? "http://127.0.0.1:8081";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": backend,
"/static": backend,
},
},
});