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/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.next/
|
||||
.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