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:
co-authored by
Claude Opus 5.5
parent
030a5bd943
commit
37279ac90d
@@ -15,3 +15,5 @@ static/media/
|
|||||||
migrate.sh
|
migrate.sh
|
||||||
Containerfile
|
Containerfile
|
||||||
.containerignore
|
.containerignore
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ original-kver.db
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
static/media/
|
static/media/
|
||||||
prod_backup/
|
prod_backup/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|||||||
+1
-1
@@ -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"]
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+1373
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
|
||||||
|
export function EntryPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
|
||||||
|
export function FeedPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
|
||||||
|
export function StatsPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
|
||||||
|
export function UserPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user