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
@@ -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