feat: Projektgerüst, gehärteten Betriebsstand und Prozess aufsetzen
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Legt die Grundlage, auf der alle weiteren Meilensteine nur noch Fachlichkeit
ergänzen: Next 16 mit App Router und TypeScript strict, Tailwind 4, Prisma 7,
Vitest 5, Playwright, ESLint, Prettier und die Hooks für Lint und Commits.

Zwei Dinge sind bewusst so gebaut und durch Tests festgehalten:

Die Datenbank ist im Betriebsstand von außen nicht erreichbar. Sie hat kein
ports:-Mapping und hängt allein im Netz "intern", das ohne Gateway läuft; nur
"web" kommt zusätzlich nach draußen. tests/betriebsstand.test.ts schlägt an,
falls ein Port-Mapping zurückkehrt. Geprüft, nicht behauptet: docker compose up
startet den Stack, 5432 ist vom Host aus zu, aus "web" offen.

pnpm pruefe:doku hält Dokumentation und Code zusammen — Version im Changelog,
Issue-Dateien gegen ihre Übersicht, keine Verweise auf fehlende Dateien, keine
fehlenden Bilder. Das Skript wurde gegen vier eingebaute Fehler gegengeprüft.

Drei Versions-Obergrenzen sind nötig und in CLAUDE.md begründet: eslint bleibt
auf 9.x, weil eslint-plugin-react keinen Nachfolger für eslint 10 hat und das
Regelwerk sonst beim Laden abbricht; typescript bleibt auf 6.0.x, weil
typescript-eslint TypeScript 7 noch nicht unterstützt; prisma ist exakt auf
7.10.0 gepinnt, weil sein latest-Tag auf einen 8.0-RC zeigt, der von
@prisma/client aber nicht.

Auth.js entfällt entgegen der ursprünglichen Vorgabe: v5 steht dauerhaft auf
Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7
nicht ab. Stattdessen openid-client und jose, wie in ProxLedger.

Dieser erste Commit geht direkt auf main, weil es noch keinen Branch gibt, von
dem abzuzweigen wäre. Ab #006 läuft jedes Issue über einen eigenen Branch.

Closes #5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-24 23:33:49 +02:00
co-authored by Claude Opus 5
commit 49ce06f206
84 changed files with 13511 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { prisma } from "@/lib/db";
/** Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet. */
export async function GET(): Promise<Response> {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ status: "ok" });
} catch (fehler) {
const meldung = fehler instanceof Error ? fehler.message : "unbekannter Fehler";
return Response.json({ status: "fehler", meldung }, { status: 503 });
}
}
+139
View File
@@ -0,0 +1,139 @@
@import "tailwindcss";
/*
* Laufzettel – Gestaltung.
* Dunkel zuerst. Ein einziger Akzent (Signal-Lime), nur fuer Aktionen, Fortschritt,
* Fokus und aktive Zustaende. Keine Verlaeufe, keine Schatten.
* Druckansichten und Etiketten sind immer hell, siehe Abschnitt @media print.
*/
@theme {
--color-grund: #0b0d10;
--color-flaeche: #14171c;
--color-flaeche-hoch: #1b1f26;
--color-linie: #262b33;
--color-text: #f3f5f7;
--color-nebentext: #98a2b3;
--color-akzent: #c6f135;
--color-auf-akzent: #0b0d10;
--color-akzent-flaeche: rgb(198 241 53 / 0.12);
--color-erledigt: #3ddc97;
--color-faellig: #f5b84a;
--color-fehler: #f87171;
--font-titel: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Manrope Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
--radius-karte: 1rem;
--radius-pille: 999px;
}
/* Heller Modus ist optional und nur aktiv, wenn er ausdruecklich gewaehlt wurde. */
:root[data-thema="hell"] {
--color-grund: #f6f7f4;
--color-flaeche: #ffffff;
--color-flaeche-hoch: #ffffff;
--color-linie: #d9ddd4;
--color-text: #0b0d10;
--color-nebentext: #5b6472;
--color-akzent: #5e8a00;
--color-auf-akzent: #ffffff;
--color-akzent-flaeche: rgb(94 138 0 / 0.1);
}
/*
* Vertrag von shadcn/ui, gespeist aus den Laufzettel-Token oben.
* Dadurch sieht jede per `pnpm dlx shadcn@latest add <bauteil>` kopierte
* Komponente sofort nach Laufzettel aus, ohne dass sie angefasst werden muss –
* und der helle Modus greift automatisch mit.
*/
:root {
--background: var(--color-grund);
--foreground: var(--color-text);
--card: var(--color-flaeche);
--card-foreground: var(--color-text);
--popover: var(--color-flaeche-hoch);
--popover-foreground: var(--color-text);
--primary: var(--color-akzent);
--primary-foreground: var(--color-auf-akzent);
--secondary: var(--color-flaeche-hoch);
--secondary-foreground: var(--color-text);
--muted: var(--color-flaeche-hoch);
--muted-foreground: var(--color-nebentext);
--accent: var(--color-akzent-flaeche);
--accent-foreground: var(--color-akzent);
--destructive: var(--color-fehler);
--destructive-foreground: var(--color-grund);
--border: var(--color-linie);
--input: var(--color-linie);
--ring: var(--color-akzent);
--radius: 0.75rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
body {
background-color: var(--color-grund);
color: var(--color-text);
font-family: var(--font-sans);
/* Am Handy darf nie horizontal gescrollt werden. */
overflow-x: hidden;
}
h1,
h2,
h3,
.zahl {
font-family: var(--font-titel);
}
code,
kbd,
samp,
.mono {
font-family: var(--font-mono);
}
/* Fokus immer sichtbar und immer im Akzent. */
:focus-visible {
outline: 2px solid var(--color-akzent);
outline-offset: 2px;
}
@media print {
body {
background: #ffffff;
color: #0b0d10;
}
.nicht-drucken {
display: none !important;
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect width="48" height="48" rx="10" fill="#0B0D10"/>
<g fill="none" stroke="#C6F135" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 8H9v32h6"/><path d="M33 8h6v32h-6"/><path d="M17.5 24.5l5 5 9.5-11"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 340 B

+24
View File
@@ -0,0 +1,24 @@
import type { Metadata, Viewport } from "next";
import "@fontsource-variable/space-grotesk";
import "@fontsource-variable/manrope";
import "@fontsource-variable/jetbrains-mono";
import "./globals.css";
export const metadata: Metadata = {
title: "laufzettel",
description: "Checklisten fuer wiederkehrende IT-Arbeiten",
};
export const viewport: Viewport = {
themeColor: "#0B0D10",
width: "device-width",
initialScale: 1,
};
export default function WurzelLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "laufzettel",
short_name: "laufzettel",
description: "Checklisten fuer wiederkehrende IT-Arbeiten",
start_url: "/",
display: "standalone",
background_color: "#0B0D10",
theme_color: "#0B0D10",
lang: "de",
icons: [
{ src: "/icon-kachel.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
+29
View File
@@ -0,0 +1,29 @@
import { Huelle } from "@/components/huelle";
export default function Startseite() {
return (
<Huelle aktiv="/">
<h1 className="text-2xl font-bold">Übersicht</h1>
<p className="mt-2 max-w-prose text-nebentext">
Das Gerüst steht: Gestaltung, Betriebsstand, Qualitätsschranken und Prozess. Die Fachlichkeit kommt ab
dem Datenmodell.
</p>
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
{ nummer: "01", titel: "Datenmodell", issue: "#006" },
{ nummer: "02", titel: "Anmeldung und Rollen", issue: "#007" },
{ nummer: "03", titel: "Betriebsmodus und Kunden", issue: "#008" },
].map((karte) => (
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
<p className="mono text-xs text-nebentext">
<span className="text-akzent">{karte.nummer}</span> · als Nächstes
</p>
<h2 className="mt-2 text-lg font-semibold">{karte.titel}</h2>
<p className="mono mt-1 text-xs text-nebentext">{karte.issue}</p>
</article>
))}
</section>
</Huelle>
);
}