Workstream 1: Projekt-Fundament & Design-System (Phase 0)
Greenfield-Next.js-15-App-Router-Gerüst (TS strict) mit: - Route-Groups (auth)/(app) inkl. loading/error/not-found je Group; Guard-Slot-Kommentar im (app)/layout.tsx (vom Auth-WS zu füllen). - "Amtlich"/Netzknoten-Designsystem: Tailwind-Tokens (Navy #1B3A5B, Signalrot #E2231A, Anthrazit, Nebelgrau, bereit/Wartung), tabular-nums, Serif-Display/Inter-Sans via CSS-Variablen, Inline-SVG-Logo. - Radix-Basiskomponenten (button/input/label/badge/tabs/dialog/select/ switch/slider); StatusBadge entspricht asset_status. - Kanonisches src/lib/env.ts (Zod, Fail-Fast) mit ALLEN DB-/Auth-/Geo-Slots inkl. AUTH_URL; isHttps-Ableitung. Zentrale i18n-Tabelle de.ts + t(). - Drizzle-Setup: client.ts (Pool-Singleton), leeres schema/index.ts-Barrel (KEIN Migrations-Eigentümer), drizzle.config.ts, .env.example. - next.config.ts: output:standalone, experimental.authInterrupts, Security-Header. Vitest + Fail-Fast-Env-Test (TDD, 5/5 grün). Bewusst KEINE Auth-Logik und KEINE fachlichen Tabellen. Verifikation: typecheck/lint/test grün; npm run build erzeugt .next/standalone/server.js; curl /anmelden -> lang="de" + FlorianNetz. next/font/google durch CSS-Variablen ersetzt (air-gapped-Build). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# FlorianNetz — Umgebungsvariablen (Beispiel; KEINE echten Geheimnisse committen)
|
||||||
|
|
||||||
|
# Node
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Datenbank (Postgres)
|
||||||
|
DATABASE_URL=postgres://floriannetz:floriannetz@localhost:5432/floriannetz
|
||||||
|
|
||||||
|
# Auth.js / NextAuth
|
||||||
|
# AUTH_SECRET muss >= 32 Zeichen sein (z. B. `openssl rand -base64 32`)
|
||||||
|
AUTH_SECRET=bitte-mindestens-32-zeichen-langes-geheimnis-setzen
|
||||||
|
# AUTH_URL bestimmt Cookie-secure: http:// = lokal (unsicher), https:// = Produktion
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
# Authentik (OIDC-Provider)
|
||||||
|
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
|
||||||
|
AUTHENTIK_CLIENT_ID=floriannetz
|
||||||
|
AUTHENTIK_CLIENT_SECRET=bitte-setzen
|
||||||
|
|
||||||
|
# Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen)
|
||||||
|
OSRM_URL=http://osrm:5000
|
||||||
|
NOMINATIM_URL=http://nominatim:8080
|
||||||
|
GEO_HTTP_TIMEOUT_MS=4000
|
||||||
|
HAVERSINE_KMH=50
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,10 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.next/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
tests/e2e/.auth/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/db/schema/index.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "",
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
19
eslint.config.mjs
Normal file
19
eslint.config.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
ignores: [".next/**", "node_modules/**", "drizzle/**"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
26
next.config.ts
Normal file
26
next.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { SECURITY_HEADERS } from "./src/lib/security/headers";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
outputFileTracingRoot: __dirname,
|
||||||
|
poweredByHeader: false,
|
||||||
|
experimental: { authInterrupts: true }, // erlaubt forbidden() aus next/navigation
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: Object.entries(SECURITY_HEADERS).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
9806
package-lock.json
generated
Normal file
9806
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "floriannetz",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:check": "drizzle-kit check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
|
"@radix-ui/react-slider": "^1.2.3",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.39.3",
|
||||||
|
"next": "^15.2.0",
|
||||||
|
"pg": "^8.13.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.5",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-config-next": "^15.2.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "^3.5.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
10
prettier.config.mjs
Normal file
10
prettier.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('prettier').Config} */
|
||||||
|
const config = {
|
||||||
|
semi: true,
|
||||||
|
singleQuote: false,
|
||||||
|
trailingComma: "all",
|
||||||
|
printWidth: 100,
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
15
src/app/(app)/error.tsx
Normal file
15
src/app/(app)/error.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function AppError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md py-16 text-center">
|
||||||
|
<p className="text-anthrazit">{t("fehler.allgemein")}</p>
|
||||||
|
<Button onClick={reset} className="mt-4">
|
||||||
|
{t("aktion.erneutVersuchen")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/(app)/layout.tsx
Normal file
21
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
|
||||||
|
// Gated App-Shell.
|
||||||
|
//
|
||||||
|
// GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1):
|
||||||
|
// Der Auth-Workstream (Workstream 3) fügt hier als ALLERERSTE Anweisung den
|
||||||
|
// serverseitigen Session-Guard ein, z. B.:
|
||||||
|
//
|
||||||
|
// import { requireSession } from "@/lib/auth/guards";
|
||||||
|
// ...
|
||||||
|
// await requireSession(); // leitet anonyme Aufrufe auf /anmelden um
|
||||||
|
//
|
||||||
|
// Lese-Seiten dürfen sich NICHT allein auf die Middleware verlassen.
|
||||||
|
export default async function AppLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// await requireSession(); // <- vom Auth-Workstream zu aktivieren
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
9
src/app/(app)/loading.tsx
Normal file
9
src/app/(app)/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function AppLoading() {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center text-sm text-anthrazit/70">
|
||||||
|
{t("aktion.laden")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/(app)/not-found.tsx
Normal file
14
src/app/(app)/not-found.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function AppNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md py-16 text-center">
|
||||||
|
<p className="text-anthrazit">{t("fehler.nichtGefunden")}</p>
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link href="/start">{t("aktion.zurueckZurStartseite")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/(app)/start/page.tsx
Normal file
24
src/app/(app)/start/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { StatusBadge } from "@/components/ui/badge";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function StartPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="font-display text-2xl text-navy">{t("app.name")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-anthrazit/70">
|
||||||
|
Vernetzte Übersicht der Fahrzeuge und Geräte der Feuerwehren.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-md border border-rand bg-white p-6">
|
||||||
|
<h2 className="font-display text-lg text-navy">Statusübersicht</h2>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge status="einsatzbereit" />
|
||||||
|
<StatusBadge status="wartung" />
|
||||||
|
<StatusBadge status="ausser_dienst" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/(auth)/anmelden/page.tsx
Normal file
33
src/app/(auth)/anmelden/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
// Platzhalter-Anmeldeseite. Der Auth-Workstream (Workstream 3) ersetzt die
|
||||||
|
// Logik durch Authentik-OIDC bzw. lokalen Login.
|
||||||
|
export default function AnmeldenPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-xl text-navy">{t("auth.anmelden")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-anthrazit/70">{t("auth.erforderlich")}</p>
|
||||||
|
<form className="mt-6 space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="username" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="passwort">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="passwort"
|
||||||
|
name="passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled>
|
||||||
|
{t("auth.anmelden")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/(auth)/error.tsx
Normal file
15
src/app/(auth)/error.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function AuthError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-anthrazit">{t("fehler.allgemein")}</p>
|
||||||
|
<Button onClick={reset} className="mt-4">
|
||||||
|
{t("aktion.erneutVersuchen")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(auth)/layout.tsx
Normal file
17
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NetzknotenLogo } from "@/components/brand/netzknoten-logo";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
// Schlankes Login-Layout ohne App-Chrome (keine Topbar/Navigation).
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-nebel px-4">
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<NetzknotenLogo size={32} />
|
||||||
|
<span className="font-display text-xl text-navy">{t("app.name")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-sm rounded-md border border-rand bg-white p-6 shadow-sm">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/(auth)/loading.tsx
Normal file
7
src/app/(auth)/loading.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export default function AuthLoading() {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-anthrazit/70">{t("aktion.laden")}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/layout.tsx
Normal file
25
src/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "FlorianNetz",
|
||||||
|
description:
|
||||||
|
"FlorianNetz — vernetzte Übersicht der Fahrzeuge und Geräte der Feuerwehren.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schriften werden über CSS-Variablen (--font-sans / --font-display) in
|
||||||
|
// globals.css gesetzt. Bewusst KEIN next/font/google: das würde zur Build-Zeit
|
||||||
|
// Google-Fonts laden und in abgeschotteten (air-gapped) Deployments/CI brechen.
|
||||||
|
// Die Stacks fallen sauber auf Inter→system-ui bzw. Source Serif 4→Georgia
|
||||||
|
// zurück; selbst gehostete Schriften können später ergänzt werden.
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body className="font-sans">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/page.tsx
Normal file
7
src/app/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// Root-Redirect. Der Auth-Workstream verfeinert dies (Login vs. /start je
|
||||||
|
// nach Session). Im Fundament leiten wir auf die Startseite weiter.
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/start");
|
||||||
|
}
|
||||||
47
src/components/brand/netzknoten-logo.tsx
Normal file
47
src/components/brand/netzknoten-logo.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Netzknoten"-Logo: vernetzte Wehren-Knoten (Navy) um einen roten Mittelpunkt
|
||||||
|
* (Florian + Netz). Rein dekorativ, daher aria-hidden — der Markenname steht
|
||||||
|
* als Text daneben.
|
||||||
|
*/
|
||||||
|
export function NetzknotenLogo({
|
||||||
|
className,
|
||||||
|
size = 28,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="FlorianNetz"
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
>
|
||||||
|
{/* Verbindungslinien (Netz) */}
|
||||||
|
<g stroke="#1B3A5B" strokeWidth="1.5" opacity="0.85">
|
||||||
|
<line x1="24" y1="24" x2="24" y2="7" />
|
||||||
|
<line x1="24" y1="24" x2="39" y2="15" />
|
||||||
|
<line x1="24" y1="24" x2="39" y2="33" />
|
||||||
|
<line x1="24" y1="24" x2="24" y2="41" />
|
||||||
|
<line x1="24" y1="24" x2="9" y2="33" />
|
||||||
|
<line x1="24" y1="24" x2="9" y2="15" />
|
||||||
|
</g>
|
||||||
|
{/* Äußere Knoten (Wehren) */}
|
||||||
|
<g fill="#1B3A5B">
|
||||||
|
<circle cx="24" cy="7" r="3.2" />
|
||||||
|
<circle cx="39" cy="15" r="3.2" />
|
||||||
|
<circle cx="39" cy="33" r="3.2" />
|
||||||
|
<circle cx="24" cy="41" r="3.2" />
|
||||||
|
<circle cx="9" cy="33" r="3.2" />
|
||||||
|
<circle cx="9" cy="15" r="3.2" />
|
||||||
|
</g>
|
||||||
|
{/* Roter Mittelpunkt (Florian) */}
|
||||||
|
<circle cx="24" cy="24" r="5" fill="#E2231A" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/components/layout/app-shell.tsx
Normal file
10
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Topbar } from "@/components/layout/topbar";
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Topbar />
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/layout/topbar.tsx
Normal file
36
src/components/layout/topbar.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { NetzknotenLogo } from "@/components/brand/netzknoten-logo";
|
||||||
|
import { de, t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/fahrzeuge", label: de.nav.fahrzeuge },
|
||||||
|
{ href: "/geraete", label: de.nav.geraete },
|
||||||
|
{ href: "/wehren", label: de.nav.wehren },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Topbar() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-rand bg-white">
|
||||||
|
<div className="mx-auto flex h-14 max-w-6xl items-center gap-6 px-4">
|
||||||
|
<Link href="/start" className="flex items-center gap-2">
|
||||||
|
<NetzknotenLogo />
|
||||||
|
<span className="font-display text-lg text-navy">{t("app.name")}</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-1" aria-label="Hauptnavigation">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="rounded px-3 py-1.5 text-sm font-medium text-anthrazit/80 transition-colors hover:bg-nebel hover:text-anthrazit"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto">
|
||||||
|
{/* Guard-Slot: Benutzermenü/Abmelden wird vom Auth-Workstream gefüllt. */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/badge.tsx
Normal file
49
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
einsatzbereit: {
|
||||||
|
label: "einsatzbereit",
|
||||||
|
cls: "bg-bereit/10 text-bereit border-bereit/30",
|
||||||
|
},
|
||||||
|
wartung: { label: "Wartung", cls: "bg-wartung/10 text-wartung border-wartung/30" },
|
||||||
|
ausser_dienst: {
|
||||||
|
label: "außer Dienst",
|
||||||
|
cls: "bg-anthrazit/10 text-anthrazit border-anthrazit/30",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StatusKey = keyof typeof STATUS;
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: StatusKey }) {
|
||||||
|
const s = STATUS[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||||
|
s.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-sm border border-rand bg-nebel px-2 py-0.5 text-xs font-medium text-anthrazit",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/button.tsx
Normal file
49
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-navy text-white hover:bg-navy/90",
|
||||||
|
signal: "bg-signal text-white hover:bg-signal/90",
|
||||||
|
outline: "border border-rand bg-white text-anthrazit hover:bg-nebel",
|
||||||
|
ghost: "text-anthrazit hover:bg-nebel",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-8 px-3",
|
||||||
|
md: "h-10 px-4",
|
||||||
|
lg: "h-11 px-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "primary",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { buttonVariants };
|
||||||
66
src/components/ui/dialog.tsx
Normal file
66
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Dialog = DialogPrimitive.Root;
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
export const DialogClose = DialogPrimitive.Close;
|
||||||
|
export const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-anthrazit/40 backdrop-blur-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
export const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-md border border-rand bg-white p-6 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
className={cn("font-display text-lg text-navy", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
className={cn("mt-1 text-sm text-anthrazit/70", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit placeholder:text-anthrazit/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium text-anthrazit peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
64
src/components/ui/select.tsx
Normal file
64
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Select = SelectPrimitive.Root;
|
||||||
|
export const SelectGroup = SelectPrimitive.Group;
|
||||||
|
export const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
export const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon aria-hidden>▾</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
export const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded border border-rand bg-white shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm text-anthrazit outline-none data-[highlighted]:bg-nebel data-[state=checked]:font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
25
src/components/ui/slider.tsx
Normal file
25
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-rand">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-navy" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-navy bg-white shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
));
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
26
src/components/ui/switch.tsx
Normal file
26
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-navy data-[state=unchecked]:bg-rand",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||||
45
src/components/ui/tabs.tsx
Normal file
45
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
export const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 border-b border-rand",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
export const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b-2 border-transparent px-3 py-2 text-sm font-medium text-anthrazit/70 transition-colors hover:text-anthrazit data-[state=active]:border-navy data-[state=active]:text-navy",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
export const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content ref={ref} className={cn("pt-4", className)} {...props} />
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
27
src/db/client.ts
Normal file
27
src/db/client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import * as schema from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-pg-Client als Pool-Singleton.
|
||||||
|
* Außerhalb der Produktion wird der Pool im globalThis gecacht, damit
|
||||||
|
* Next.js-HMR keine Verbindungen leakt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// Pool-Cache für HMR außerhalb Produktion.
|
||||||
|
var __floriannetzPool: Pool | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPool(): Pool {
|
||||||
|
return new Pool({ connectionString: env.DATABASE_URL });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool =
|
||||||
|
env.NODE_ENV === "production"
|
||||||
|
? createPool()
|
||||||
|
: (globalThis.__floriannetzPool ??= createPool());
|
||||||
|
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
|
export type DB = typeof db;
|
||||||
5
src/db/schema/index.ts
Normal file
5
src/db/schema/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Barrel für das Datenbankschema.
|
||||||
|
// Wird vom Datenbank-Workstream (Workstream 2) mit Tabellen, Enums und Indizes
|
||||||
|
// gefüllt. Dieser Workstream (Fundament) definiert bewusst KEINE fachlichen
|
||||||
|
// Tabellen und ist NICHT Migrations-Eigentümer.
|
||||||
|
export {};
|
||||||
73
src/lib/__tests__/env.test.ts
Normal file
73
src/lib/__tests__/env.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-Fast-Verhalten der kanonischen Env-Ladung.
|
||||||
|
* Wir importieren env.ts dynamisch, damit jeder Test mit frischem process.env
|
||||||
|
* und frischem Modul-Cache läuft.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VALID_ENV = {
|
||||||
|
NODE_ENV: "test",
|
||||||
|
DATABASE_URL: "postgres://user:pass@localhost:5432/floriannetz",
|
||||||
|
AUTH_SECRET: "x".repeat(32),
|
||||||
|
AUTH_URL: "http://localhost:3000",
|
||||||
|
AUTHENTIK_ISSUER: "http://localhost:9000/application/o/floriannetz/",
|
||||||
|
AUTHENTIK_CLIENT_ID: "client-id",
|
||||||
|
AUTHENTIK_CLIENT_SECRET: "client-secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot: NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
snapshot = { ...process.env };
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = snapshot;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setEnv(overrides: Record<string, string | undefined>) {
|
||||||
|
for (const [k, v] of Object.entries({ ...VALID_ENV, ...overrides })) {
|
||||||
|
if (v === undefined) {
|
||||||
|
delete process.env[k];
|
||||||
|
} else {
|
||||||
|
process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("env", () => {
|
||||||
|
it("lädt gültige Umgebungsvariablen erfolgreich", async () => {
|
||||||
|
setEnv({});
|
||||||
|
const mod = await import("../env");
|
||||||
|
expect(mod.env.DATABASE_URL).toBe(VALID_ENV.DATABASE_URL);
|
||||||
|
expect(mod.env.GEO_HTTP_TIMEOUT_MS).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt isHttps=false bei http AUTH_URL", async () => {
|
||||||
|
setEnv({ AUTH_URL: "http://localhost:3000" });
|
||||||
|
const mod = await import("../env");
|
||||||
|
expect(mod.isHttps).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt isHttps=true bei https AUTH_URL", async () => {
|
||||||
|
setEnv({ AUTH_URL: "https://florian.example.at" });
|
||||||
|
const mod = await import("../env");
|
||||||
|
expect(mod.isHttps).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft Fehler (Fail-Fast) bei fehlender DATABASE_URL", async () => {
|
||||||
|
setEnv({ DATABASE_URL: undefined });
|
||||||
|
await expect(import("../env")).rejects.toThrow(
|
||||||
|
/Umgebungsvariablen-Validierung fehlgeschlagen/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft Fehler bei zu kurzem AUTH_SECRET", async () => {
|
||||||
|
setEnv({ AUTH_SECRET: "kurz" });
|
||||||
|
await expect(import("../env")).rejects.toThrow(
|
||||||
|
/Umgebungsvariablen-Validierung fehlgeschlagen/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
27
src/lib/env.ts
Normal file
27
src/lib/env.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const serverSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
// Auth (vom Auth-WS konsumiert; AUTH_URL bestimmt Cookie-secure):
|
||||||
|
AUTH_SECRET: z.string().min(32),
|
||||||
|
AUTH_URL: z.string().url(),
|
||||||
|
AUTH_TRUST_HOST: z.coerce.boolean().default(true),
|
||||||
|
AUTHENTIK_ISSUER: z.string().url(),
|
||||||
|
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
||||||
|
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
|
||||||
|
// Geo:
|
||||||
|
OSRM_URL: z.string().url().default("http://osrm:5000"),
|
||||||
|
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),
|
||||||
|
GEO_HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(4000),
|
||||||
|
HAVERSINE_KMH: z.coerce.number().positive().default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = serverSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("Ungültige Umgebungsvariablen:", parsed.error.flatten().fieldErrors);
|
||||||
|
throw new Error("Umgebungsvariablen-Validierung fehlgeschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = parsed.data;
|
||||||
|
export const isHttps = env.AUTH_URL.startsWith("https://");
|
||||||
55
src/lib/i18n/de.ts
Normal file
55
src/lib/i18n/de.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export const de = {
|
||||||
|
app: { name: "FlorianNetz" },
|
||||||
|
nav: {
|
||||||
|
fahrzeuge: "Fahrzeuge",
|
||||||
|
geraete: "Geräte",
|
||||||
|
wehren: "Wehren",
|
||||||
|
verwaltung: "Verwaltung",
|
||||||
|
admin: "Administration",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
anmelden: "Anmelden",
|
||||||
|
abmelden: "Abmelden",
|
||||||
|
erforderlich: "Anmeldung erforderlich.",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
einsatzbereit: "einsatzbereit",
|
||||||
|
wartung: "Wartung",
|
||||||
|
ausser_dienst: "außer Dienst",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
meinStandort: "Meinen Standort verwenden",
|
||||||
|
suchen: "Suchen",
|
||||||
|
keineTreffer: "Keine Treffer.",
|
||||||
|
luftlinie: "Luftlinie (geschätzt)",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
eckdaten: "Eckdaten",
|
||||||
|
beladung: "Beladung",
|
||||||
|
keineEckdaten: "Keine Eckdaten erfasst.",
|
||||||
|
imGeraetehaus: "im Gerätehaus",
|
||||||
|
},
|
||||||
|
fehler: {
|
||||||
|
allgemein: "Es ist ein Fehler aufgetreten.",
|
||||||
|
keineBerechtigung: "Keine Berechtigung.",
|
||||||
|
nichtGefunden: "Seite nicht gefunden.",
|
||||||
|
},
|
||||||
|
aktion: {
|
||||||
|
erneutVersuchen: "Erneut versuchen",
|
||||||
|
laden: "Wird geladen …",
|
||||||
|
zurueckZurStartseite: "Zur Startseite",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Leaf = string;
|
||||||
|
type Paths<T> = T extends Leaf
|
||||||
|
? ""
|
||||||
|
: {
|
||||||
|
[K in keyof T & string]: T[K] extends Leaf ? K : `${K}.${Paths<T[K]>}`;
|
||||||
|
}[keyof T & string];
|
||||||
|
|
||||||
|
export function t(path: Paths<typeof de>): string {
|
||||||
|
return path
|
||||||
|
.split(".")
|
||||||
|
.reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], de) as string;
|
||||||
|
}
|
||||||
12
src/lib/security/headers.ts
Normal file
12
src/lib/security/headers.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Sicherheits-Header, eingehängt in next.config.ts.
|
||||||
|
* CSP bewusst konservativ; bei Bedarf von Feature-Workstreams erweitert.
|
||||||
|
*/
|
||||||
|
export const SECURITY_HEADERS: Record<string, string> = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
"Permissions-Policy": "geolocation=(self), camera=(), microphone=()",
|
||||||
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
||||||
|
"X-DNS-Prefetch-Control": "off",
|
||||||
|
};
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
36
src/lib/validation/common.ts
Normal file
36
src/lib/validation/common.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Zod-Schemas, die an mehreren Grenzen (FormData, JSON, searchParams)
|
||||||
|
* wiederverwendet werden. Feature-Workstreams bauen darauf auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const uuidSchema = z.string().uuid({ message: "Ungültige ID." });
|
||||||
|
|
||||||
|
export const nonEmptyText = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { message: "Pflichtfeld." });
|
||||||
|
|
||||||
|
export const optionalText = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === "" ? undefined : v));
|
||||||
|
|
||||||
|
export const latitudeSchema = z
|
||||||
|
.number({ invalid_type_error: "Ungültiger Breitengrad." })
|
||||||
|
.min(-90)
|
||||||
|
.max(90);
|
||||||
|
|
||||||
|
export const longitudeSchema = z
|
||||||
|
.number({ invalid_type_error: "Ungültiger Längengrad." })
|
||||||
|
.min(-180)
|
||||||
|
.max(180);
|
||||||
|
|
||||||
|
export const paginationSchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Pagination = z.infer<typeof paginationSchema>;
|
||||||
39
src/styles/globals.css
Normal file
39
src/styles/globals.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--font-sans: "Inter", system-ui, sans-serif;
|
||||||
|
--font-display: "Source Serif 4", Georgia, serif;
|
||||||
|
|
||||||
|
--color-navy: #1b3a5b;
|
||||||
|
--color-signal: #e2231a;
|
||||||
|
--color-anthrazit: #1a2530;
|
||||||
|
--color-nebel: #f6f8fa;
|
||||||
|
--color-bereit: #1f8f5a;
|
||||||
|
--color-wartung: #b5460f;
|
||||||
|
--color-rand: #d9dee5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-nebel text-anthrazit antialiased;
|
||||||
|
font-feature-settings: "tnum" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
@apply font-display text-navy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.tabular-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tailwind.config.ts
Normal file
33
tailwind.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
navy: "#1B3A5B",
|
||||||
|
signal: "#E2231A",
|
||||||
|
anthrazit: "#1A2530",
|
||||||
|
nebel: "#F6F8FA",
|
||||||
|
bereit: "#1F8F5A",
|
||||||
|
wartung: "#B5460F",
|
||||||
|
rand: "#D9DEE5",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", "Inter", "system-ui", "sans-serif"],
|
||||||
|
display: ["var(--font-display)", "Source Serif 4", "Georgia", "serif"],
|
||||||
|
},
|
||||||
|
fontFeatureSettings: {
|
||||||
|
tnum: '"tnum" 1',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: "2px",
|
||||||
|
DEFAULT: "4px",
|
||||||
|
md: "6px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user