first working release

This commit is contained in:
2026-06-13 15:11:49 +02:00
parent b97d574697
commit 550adb5114
29 changed files with 4438 additions and 131 deletions

9
.gitignore vendored
View File

@@ -39,3 +39,12 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# prisma — Datenbankdatei NICHT einchecken
/prisma/*.db
/prisma/*.db-journal
/*.db
/*.db-journal

2745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,24 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^7.6.13",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/tailwindcss": "^3.0.11",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
}

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

52
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,52 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
// A physical location in the building (e.g., "Serverraum", "Wohnzimmer")
model Location {
id Int @id @default(autoincrement())
name String
description String? // Optional Markdown text
// A location can be the start OR end of many cables
startCables Cable[] @relation("StartLocation")
endCables Cable[] @relation("EndLocation")
}
// A single cable run documented in the system
model Cable {
id Int @id @default(autoincrement())
identifier String // e.g. "Kabel 01", "LAN-01"
description String? // Short plain-text summary
notes String? // Long-form Markdown notes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
startLocationId Int
endLocationId Int
startLocation Location @relation("StartLocation", fields: [startLocationId], references: [id])
endLocation Location @relation("EndLocation", fields: [endLocationId], references: [id])
cores Core[] // One cable can have many cores/wires
}
// A single wire/core within a cable (e.g., "Blau", "Rot/Weiß")
model Core {
id Int @id @default(autoincrement())
color String // Color designation, e.g. "Blau", "Braun/Weiß"
notes String? // Optional Markdown notes
cableId Int
cable Cable @relation(fields: [cableId], references: [id], onDelete: Cascade)
}

View File

@@ -0,0 +1,58 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// Discriminated union return type allows the client to branch on success/failure
export type CreateCableResult =
| { success: true; cableId: number }
| { success: false; error: string };
/**
* Creates a new Cable and returns either the new cable's ID (on success)
* or an error message (on validation/DB failure).
* The client component handles navigation to the detail page on success.
*/
export async function createCable(
formData: FormData
): Promise<CreateCableResult> {
const identifier = (formData.get("identifier") as string)?.trim();
const description = (formData.get("description") as string)?.trim();
const notes = (formData.get("notes") as string)?.trim();
const startLocationId = Number(formData.get("startLocationId"));
const endLocationId = Number(formData.get("endLocationId"));
if (!identifier) {
return { success: false, error: "Bezeichnung ist erforderlich." };
}
if (!startLocationId || !endLocationId) {
return { success: false, error: "Start- und End-Ort müssen ausgewählt werden." };
}
try {
const cable = await prisma.cable.create({
data: {
identifier,
description: description || null,
notes: notes || null,
startLocationId,
endLocationId,
},
});
revalidatePath("/cables");
return { success: true, cableId: cable.id };
} catch (e) {
console.error("createCable failed:", e);
return { success: false, error: "Fehler beim Speichern des Kabels." };
}
}
/**
* Deletes a Cable and all its Cores (via Prisma's onDelete: Cascade).
* Navigation back to /cables is handled by the client component.
*/
export async function deleteCable(id: number): Promise<void> {
await prisma.cable.delete({ where: { id } });
revalidatePath("/cables");
}

View File

@@ -0,0 +1,52 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export type FormState = {
error?: string;
success?: boolean;
};
/**
* Adds a Core/Wire to an existing Cable. Designed for useActionState.
*/
export async function createCore(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const cableId = Number(formData.get("cableId"));
const color = (formData.get("color") as string)?.trim();
const notes = (formData.get("notes") as string)?.trim();
if (!color) {
return { error: "Farbe ist erforderlich." };
}
if (!cableId || isNaN(cableId)) {
return { error: "Ungültige Kabel-ID." };
}
try {
await prisma.core.create({
data: {
cableId,
color,
notes: notes || null,
},
});
revalidatePath(`/cables/${cableId}`);
return { success: true };
} catch (e) {
console.error("createCore failed:", e);
return { error: "Fehler beim Speichern der Ader." };
}
}
/**
* Deletes a single Core by ID.
*/
export async function deleteCore(id: number, cableId: number): Promise<void> {
await prisma.core.delete({ where: { id } });
revalidatePath(`/cables/${cableId}`);
}

View File

@@ -0,0 +1,52 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// Shared form state shape used with useActionState
export type FormState = {
error?: string;
success?: boolean;
};
/**
* Creates a new Location. Designed for use with React's useActionState hook.
*/
export async function createLocation(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = (formData.get("name") as string)?.trim();
const description = (formData.get("description") as string)?.trim();
if (!name) {
return { error: "Name ist erforderlich." };
}
try {
await prisma.location.create({
data: {
name,
description: description || null,
},
});
// Invalidate both pages that show location data
revalidatePath("/locations");
revalidatePath("/cables");
return { success: true };
} catch (e) {
console.error("createLocation failed:", e);
return { error: "Fehler beim Anlegen des Ortes. Bitte erneut versuchen." };
}
}
/**
* Deletes a Location by ID. The UI prevents calling this if the location
* is still referenced by cables (enforced via disabled state on the button).
*/
export async function deleteLocation(id: number): Promise<void> {
await prisma.location.delete({ where: { id } });
revalidatePath("/locations");
revalidatePath("/cables");
}

View File

@@ -0,0 +1,141 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import MarkdownContent from "@/components/ui/MarkdownContent";
import CoresList from "@/components/cores/CoresList";
import NewCoreForm from "@/components/cores/NewCoreForm";
import DeleteCableButton from "@/components/cables/DeleteCableButton";
// Next.js 15: dynamic route params are a Promise
interface CableDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function CableDetailPage({
params,
}: CableDetailPageProps) {
const { id } = await params;
const cableId = parseInt(id, 10);
if (isNaN(cableId)) notFound();
const cable = await prisma.cable.findUnique({
where: { id: cableId },
include: {
startLocation: true,
endLocation: true,
cores: { orderBy: { id: "asc" } },
},
});
if (!cable) notFound();
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-slate-400">
<Link href="/cables" className="hover:text-slate-700 transition-colors">
Kabel-Übersicht
</Link>
<span></span>
<span className="text-slate-700 font-medium">{cable.identifier}</span>
</nav>
{/* ── Header card ── */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">
{cable.identifier}
</h1>
{cable.description && (
<p className="text-slate-500 mt-1">{cable.description}</p>
)}
<p className="text-xs text-slate-400 mt-2 tabular-nums">
Erstellt:{" "}
{new Date(cable.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}{" "}
· Aktualisiert:{" "}
{new Date(cable.updatedAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</div>
<DeleteCableButton id={cable.id} identifier={cable.identifier} />
</div>
{/* Route visualization */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] gap-3 items-center">
{/* Start location */}
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4">
<p className="text-xs text-blue-400 font-medium uppercase tracking-wide mb-1">
Start-Ort
</p>
<p className="text-blue-900 font-semibold">{cable.startLocation.name}</p>
{cable.startLocation.description && (
<p className="text-blue-600 text-xs mt-1">
{cable.startLocation.description}
</p>
)}
</div>
{/* Arrow */}
<div className="flex justify-center text-slate-300">
<svg className="w-6 h-6 rotate-90 sm:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
{/* End location */}
<div className="bg-emerald-50 border border-emerald-100 rounded-lg p-4">
<p className="text-xs text-emerald-400 font-medium uppercase tracking-wide mb-1">
End-Ort
</p>
<p className="text-emerald-900 font-semibold">{cable.endLocation.name}</p>
{cable.endLocation.description && (
<p className="text-emerald-600 text-xs mt-1">
{cable.endLocation.description}
</p>
)}
</div>
</div>
</div>
{/* ── Notes card (only shown if notes exist) ── */}
{cable.notes && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
<h2 className="text-base font-semibold text-slate-900 mb-3">
Notizen
</h2>
<MarkdownContent content={cable.notes} />
</div>
)}
{/* ── Cores card ── */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Section header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">Adern</h2>
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
{cable.cores.length}
</span>
</div>
<div className="p-6 space-y-6">
{/* Existing cores list */}
<CoresList cores={cable.cores} cableId={cable.id} />
{/* Divider before add-form */}
<div className="border-t border-slate-100 pt-6">
<NewCoreForm cableId={cable.id} />
</div>
</div>
</div>
</div>
);
}

49
src/app/cables/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { prisma } from "@/lib/prisma";
import CablesTable from "@/components/cables/CablesTable";
import NewCableForm from "@/components/cables/NewCableForm";
import Link from "next/link";
export default async function CablesPage() {
const [cables, locations] = await Promise.all([
prisma.cable.findMany({
include: {
startLocation: true,
endLocation: true,
_count: { select: { cores: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.location.findMany({ orderBy: { name: "asc" } }),
]);
return (
<div>
{/* Page header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Kabel-Übersicht</h1>
<p className="text-slate-500 mt-1 text-sm">
Alle dokumentierten Kabel mit Start-Ort, End-Ort und Adern.
</p>
</div>
{/* Show warning if no locations exist yet */}
{locations.length === 0 ? (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5 mb-6 text-sm text-amber-800">
<strong>Hinweis:</strong> Es sind noch keine Orte angelegt.{" "}
<Link
href="/locations"
className="font-semibold underline hover:no-underline"
>
Bitte legen Sie zuerst mindestens einen Ort an.
</Link>
</div>
) : (
<div className="mb-6">
<NewCableForm locations={locations} />
</div>
)}
<CablesTable cables={cables} />
</div>
);
}

View File

@@ -1,26 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
/* Push content below the fixed mobile header */
@media (max-width: 1023px) {
body > div > main {
padding-top: 4rem;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,33 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import Navigation from "@/components/Navigation";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Kabel-Dokumentation",
description: "Dokumentation aller Netzwerk-, Strom- und Signalkabel im Haus",
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="de">
<body className={`${inter.className} bg-slate-50 text-slate-900`}>
<div className="flex min-h-screen">
{/* Sidebar — fixed on desktop, slide-in overlay on mobile */}
<Navigation />
{/* Main content — offset by sidebar width on large screens */}
<main className="flex-1 lg:ml-64 p-6 md:p-8">
<div className="max-w-5xl mx-auto">{children}</div>
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,39 @@
import { prisma } from "@/lib/prisma";
import NewLocationForm from "@/components/locations/NewLocationForm";
import LocationsTable from "@/components/locations/LocationsTabe";
export default async function LocationsPage() {
const locations = await prisma.location.findMany({
include: {
_count: {
select: {
startCables: true,
endCables: true,
},
},
},
orderBy: { name: "asc" },
});
return (
<div>
{/* Page header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Orte verwalten</h1>
<p className="text-slate-500 mt-1 text-sm">
Räume und Installationspunkte für die Kabeldokumentation.
</p>
</div>
{/* Two-column layout: form left, table right */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<div className="lg:col-span-1">
<NewLocationForm />
</div>
<div className="lg:col-span-2">
<LocationsTable locations={locations} />
</div>
</div>
</div>
);
}

View File

@@ -1,65 +1,6 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
// The root URL redirects to the cable overview
export default function HomePage() {
redirect("/cables");
}

View File

@@ -0,0 +1,188 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
const navItems = [
{
href: "/cables",
label: "Kabel-Übersicht",
icon: (
// Simple cable/plug icon via inline SVG
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
href: "/locations",
label: "Orte verwalten",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
export default function Navigation() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<>
{/* ── Mobile top bar ── */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between
bg-slate-900 px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-white font-semibold text-sm">Kabel-Manager</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-300 hover:text-white p-1 rounded"
aria-label="Navigation öffnen"
>
{mobileOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
{/* ── Desktop sidebar ── */}
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-64 bg-slate-900 flex-col z-40">
<div className="flex flex-col h-full">
{/* Brand / Logo area */}
<div className="px-5 py-5 border-b border-slate-700/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
<p className="text-slate-400 text-xs">Hausverkabelung</p>
</div>
</div>
</div>
{/* Navigation links */}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
${isActive
? "bg-blue-600 text-white"
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
}
`}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* Footer note */}
<div className="px-5 py-4 border-t border-slate-700/50">
<p className="text-slate-500 text-xs">
Kabel, Adern & Orte dokumentieren
</p>
</div>
</div>
</aside>
{/* ── Mobile drawer ── */}
<aside
className={`
lg:hidden fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40
transform transition-transform duration-200 ease-in-out
${mobileOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<div className="pt-14"> {/* offset mobile top bar */}
{/* Brand / Logo area */}
<div className="px-5 py-5 border-b border-slate-700/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
<p className="text-slate-400 text-xs">Hausverkabelung</p>
</div>
</div>
</div>
{/* Navigation links */}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
${isActive
? "bg-blue-600 text-white"
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
}
`}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* Footer note */}
<div className="px-5 py-4 border-t border-slate-700/50">
<p className="text-slate-500 text-xs">
Kabel, Adern & Orte dokumentieren
</p>
</div>
</div>
</aside>
{/* ── Mobile backdrop overlay ── */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import type { CableWithLocations } from "@/lib/types";
interface CablesTableProps {
cables: CableWithLocations[];
}
export default function CablesTable({ cables }: CablesTableProps) {
const [query, setQuery] = useState("");
const filtered = query.trim()
? cables.filter((cable) => {
const q = query.toLowerCase();
return (
cable.identifier.toLowerCase().includes(q) ||
(cable.description ?? "").toLowerCase().includes(q) ||
(cable.notes ?? "").toLowerCase().includes(q) ||
cable.startLocation.name.toLowerCase().includes(q) ||
cable.endLocation.name.toLowerCase().includes(q)
);
})
: cables;
if (cables.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-14 text-center shadow-sm">
<div className="text-4xl mb-4">🔌</div>
<p className="text-slate-600 font-medium">
Noch keine Kabel dokumentiert.
</p>
<p className="text-slate-400 text-sm mt-1">
Legen Sie oben Ihr erstes Kabel an.
</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Header with search */}
<div className="px-6 py-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-2 flex-1">
<h2 className="text-base font-semibold text-slate-900">Alle Kabel</h2>
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
{filtered.length}{query.trim() ? ` / ${cables.length}` : ""}
</span>
</div>
{/* Search input */}
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Kabel suchen…"
className="
pl-9 pr-3 py-1.5 text-sm
border border-slate-200 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 w-full sm:w-56
"
/>
</div>
</div>
{/* No results */}
{filtered.length === 0 ? (
<div className="px-6 py-12 text-center text-slate-400 text-sm">
Keine Kabel für {query}&quot; gefunden.
</div>
) : (
/* Responsive table */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-100">
<tr>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Bezeichnung
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Start-Ort
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
End-Ort
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden sm:table-cell">
Adern
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
Angelegt
</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((cable) => (
<tr key={cable.id} className="hover:bg-slate-50/60 group">
<td className="px-6 py-4">
<Link
href={`/cables/${cable.id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
{cable.identifier}
</Link>
{cable.description && (
<p className="text-slate-400 text-xs mt-0.5">
{cable.description}
</p>
)}
</td>
<td className="px-6 py-4">
<span className="
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-blue-50 text-blue-700
">
{cable.startLocation.name}
</span>
</td>
<td className="px-6 py-4">
<span className="
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-emerald-50 text-emerald-700
">
{cable.endLocation.name}
</span>
</td>
<td className="px-6 py-4 hidden sm:table-cell">
<span className="
inline-flex items-center px-2 py-0.5 rounded text-xs
bg-slate-100 text-slate-600 tabular-nums
">
{cable._count.cores}{" "}
{cable._count.cores === 1 ? "Ader" : "Adern"}
</span>
</td>
<td className="px-6 py-4 text-slate-400 text-xs hidden md:table-cell tabular-nums">
{new Date(cable.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="px-6 py-4 text-right">
<Link
href={`/cables/${cable.id}`}
className="
text-xs text-slate-400 hover:text-slate-700
opacity-0 group-hover:opacity-100 transition-opacity duration-150
"
>
Details
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteCable } from "@/actions/cableActions";
interface DeleteCableButtonProps {
id: number;
identifier: string;
}
export default function DeleteCableButton({
id,
identifier,
}: DeleteCableButtonProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleDelete = () => {
if (
!confirm(
`Kabel „${identifier}" wirklich löschen?\n\nAlle zugehörigen Adern werden ebenfalls gelöscht.`
)
) {
return;
}
startTransition(async () => {
await deleteCable(id);
// Navigation is handled client-side (server action does not redirect)
router.push("/cables");
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="
inline-flex items-center gap-1.5 px-3 py-1.5
border border-red-200 text-red-600
hover:bg-red-50 hover:border-red-300
disabled:opacity-50 disabled:cursor-not-allowed
text-sm font-medium rounded-lg transition-colors duration-150
flex-shrink-0
"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{isPending ? "Wird gelöscht…" : "Kabel löschen"}
</button>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { Location } from "@/generated/prisma/client";
import { createCable } from "@/actions/cableActions";
interface NewCableFormProps {
locations: Location[];
}
export default function NewCableForm({ locations }: NewCableFormProps) {
const [isOpen, setIsOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setError(null);
startTransition(async () => {
const result = await createCable(formData);
if (!result.success) {
setError(result.error);
} else {
router.push(`/cables/${result.cableId}`);
}
});
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="
inline-flex items-center gap-2 px-4 py-2.5
bg-blue-600 hover:bg-blue-700 text-white
text-sm font-medium rounded-lg transition-colors duration-150
"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neues Kabel anlegen
</button>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Form header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h2 className="text-base font-semibold text-slate-900">
Neues Kabel anlegen
</h2>
<button
onClick={() => { setIsOpen(false); setError(null); }}
className="text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Formular schließen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form fields */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Identifier */}
<div className="md:col-span-2">
<label
htmlFor="cable-identifier"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Bezeichnung <span className="text-red-500">*</span>
</label>
<input
id="cable-identifier"
name="identifier"
type="text"
placeholder="z. B. Kabel 01, LAN-01, Strom-KG-01"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Start location */}
<div>
<label
htmlFor="cable-start"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Start-Ort <span className="text-red-500">*</span>
</label>
<select
id="cable-start"
name="startLocationId"
required
defaultValue=""
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
"
>
<option value="" disabled>Ort auswählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</div>
{/* End location */}
<div>
<label
htmlFor="cable-end"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
End-Ort <span className="text-red-500">*</span>
</label>
<select
id="cable-end"
name="endLocationId"
required
defaultValue=""
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
"
>
<option value="" disabled>Ort auswählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</div>
{/* Short description */}
<div className="md:col-span-2">
<label
htmlFor="cable-description"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Kurzbeschreibung{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="cable-description"
name="description"
type="text"
placeholder="z. B. Netzwerkkabel, CAT 7"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Notes (Markdown) */}
<div className="md:col-span-2">
<label
htmlFor="cable-notes"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Notizen{" "}
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
</label>
<textarea
id="cable-notes"
name="notes"
rows={3}
placeholder="Verlegeweg, Besonderheiten, Querschnitt…"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 resize-y
"
/>
</div>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{error}
</p>
)}
{/* Action buttons */}
<div className="flex items-center gap-3 pt-1">
<button
type="submit"
disabled={isPending}
className="
px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
text-white text-sm font-medium rounded-lg
transition-colors duration-150 disabled:cursor-not-allowed
"
>
{isPending ? "Wird gespeichert…" : "Kabel anlegen"}
</button>
<button
type="button"
onClick={() => { setIsOpen(false); setError(null); }}
className="
px-5 py-2 bg-slate-100 hover:bg-slate-200
text-slate-700 text-sm font-medium rounded-lg
transition-colors duration-150
"
>
Abbrechen
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import type { Core } from "@prisma/client";
import DeleteCoreButton from "./DeleteCoreButton";
import MarkdownContent from "@/components/ui/MarkdownContent";
interface CoresListProps {
cores: Core[];
cableId: number;
}
// Maps German color names to Tailwind background colors for the indicator dot.
// Supports "Farbe1/Farbe2" notation (uses the first color).
const COLOR_MAP: Record<string, string> = {
blau: "bg-blue-500",
rot: "bg-red-500",
grün: "bg-green-500",
gelb: "bg-yellow-400",
braun: "bg-amber-800",
orange: "bg-orange-500",
grau: "bg-slate-400",
schwarz: "bg-slate-900",
weiß: "bg-white border border-slate-300",
violett: "bg-violet-500",
lila: "bg-purple-500",
pink: "bg-pink-400",
türkis: "bg-cyan-400",
beige: "bg-amber-200",
};
function getColorClass(color: string): string {
// Handle "Rot/Weiß" or "Blau-Weiß" by taking the first color segment
const primary = color.toLowerCase().split(/[/\-,]/)[0].trim();
return COLOR_MAP[primary] ?? "bg-slate-300";
}
export default function CoresList({ cores, cableId }: CoresListProps) {
if (cores.length === 0) {
return (
<p className="text-slate-400 text-sm text-center py-4">
Noch keine Adern dokumentiert.
</p>
);
}
return (
<div className="space-y-2">
{cores.map((core) => (
<div
key={core.id}
className="
flex items-start gap-3 px-4 py-3
rounded-lg border border-slate-100
hover:bg-slate-50/80 transition-colors duration-100
"
>
{/* Color indicator dot */}
<div
className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 ${getColorClass(core.color)}`}
title={core.color}
/>
{/* Color name + optional notes */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900">{core.color}</p>
{core.notes && (
<div className="mt-1 text-slate-500">
<MarkdownContent content={core.notes} size="compact" />
</div>
)}
</div>
{/* Delete button */}
<DeleteCoreButton id={core.id} cableId={cableId} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useTransition } from "react";
import { deleteCore } from "@/actions/coreActions";
interface DeleteCoreButtonProps {
id: number;
cableId: number;
}
export default function DeleteCoreButton({ id, cableId }: DeleteCoreButtonProps) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!confirm("Diese Ader wirklich löschen?")) return;
startTransition(async () => {
await deleteCore(id, cableId);
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
aria-label="Ader löschen"
className="
text-slate-300 hover:text-red-500
disabled:text-slate-200 disabled:cursor-not-allowed
transition-colors duration-150 flex-shrink-0 p-0.5
"
>
{isPending ? (
<span className="text-xs"></span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormStatus } from "react-dom";
import { createCore } from "@/actions/coreActions";
import type { FormState } from "@/actions/coreActions";
const initialState: FormState = {};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="
inline-flex items-center gap-2
px-4 py-2 bg-blue-600 hover:bg-blue-700
disabled:bg-blue-400 disabled:cursor-not-allowed
text-white text-sm font-medium rounded-lg
transition-colors duration-150
"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{pending ? "Wird gespeichert…" : "Ader hinzufügen"}
</button>
);
}
interface NewCoreFormProps {
cableId: number;
}
export default function NewCoreForm({ cableId }: NewCoreFormProps) {
const [state, formAction] = useActionState(createCore, initialState);
const formRef = useRef<HTMLFormElement>(null);
// Auto-reset the form fields on success so the user can add the next core quickly
useEffect(() => {
if (state.success) {
formRef.current?.reset();
// Re-focus the color input for rapid data entry
formRef.current?.querySelector<HTMLInputElement>('input[name="color"]')?.focus();
}
}, [state.success]);
return (
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3">
Ader hinzufügen
</h3>
<form ref={formRef} action={formAction} className="space-y-3">
{/* Hidden cable ID */}
<input type="hidden" name="cableId" value={cableId} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Color field */}
<div>
<label
htmlFor="core-color"
className="block text-xs font-medium text-slate-600 mb-1"
>
Farbe <span className="text-red-500">*</span>
</label>
<input
id="core-color"
name="color"
type="text"
placeholder="z. B. Blau, Rot/Weiß, Braun"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Notes field */}
<div>
<label
htmlFor="core-notes"
className="block text-xs font-medium text-slate-600 mb-1"
>
Notizen{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="core-notes"
name="notes"
type="text"
placeholder="z. B. Phase L1, Neutral, Erdung"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
</div>
{/* Error feedback */}
{state.error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{state.error}
</p>
)}
<SubmitButton />
</form>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useTransition } from "react";
import { deleteLocation } from "@/actions/locationActions";
interface DeleteLocationButtonProps {
id: number;
/** Number of cables referencing this location — disables deletion if > 0 */
cableCount: number;
}
export default function DeleteLocationButton({
id,
cableCount,
}: DeleteLocationButtonProps) {
const [isPending, startTransition] = useTransition();
const inUse = cableCount > 0;
const handleDelete = () => {
if (inUse) return; // Safety guard (UI also disables the button)
if (!confirm("Diesen Ort wirklich löschen?")) return;
startTransition(async () => {
await deleteLocation(id);
});
};
return (
<button
onClick={handleDelete}
disabled={isPending || inUse}
title={
inUse
? `Nicht löschbar — wird von ${cableCount} Kabel(n) verwendet`
: "Ort löschen"
}
className="
text-xs font-medium transition-colors duration-150
text-red-500 hover:text-red-700
disabled:text-slate-300 disabled:cursor-not-allowed
"
>
{isPending ? "…" : "Löschen"}
</button>
);
}

View File

@@ -0,0 +1,89 @@
import type { LocationWithCounts } from "@/lib/types";
import DeleteLocationButton from "./DeleteLocationButton";
interface LocationsTableProps {
locations: LocationWithCounts[];
}
export default function LocationsTable({ locations }: LocationsTableProps) {
if (locations.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-sm">
<p className="text-3xl mb-3">📍</p>
<p className="text-slate-500 font-medium">Noch keine Orte angelegt.</p>
<p className="text-slate-400 text-sm mt-1">
Legen Sie links Ihren ersten Ort an.
</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Table header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">
Alle Orte
</h2>
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
{locations.length}
</span>
</div>
{/* Responsive table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-100">
<tr>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Name
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
Beschreibung
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Kabel
</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{locations.map((location) => {
const totalCables =
location._count.startCables + location._count.endCables;
return (
<tr key={location.id} className="hover:bg-slate-50/60">
<td className="px-6 py-4">
<span className="font-medium text-slate-900">
{location.name}
</span>
</td>
<td className="px-6 py-4 text-slate-500 hidden md:table-cell">
{location.description ? (
<span className="truncate block max-w-xs">
{location.description}
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-6 py-4">
<span className="text-slate-500 tabular-nums">
{totalCables}
</span>
</td>
<td className="px-6 py-4 text-right">
<DeleteLocationButton
id={location.id}
cableCount={totalCables}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormStatus } from "react-dom";
import { createLocation } from "@/actions/locationActions";
import type { FormState } from "@/actions/locationActions";
const initialState: FormState = {};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="
w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
disabled:bg-blue-400 disabled:cursor-not-allowed
text-white text-sm font-medium rounded-lg
transition-colors duration-150
"
>
{pending ? "Wird gespeichert…" : "Ort anlegen"}
</button>
);
}
export default function NewLocationForm() {
const [state, formAction] = useActionState(createLocation, initialState);
const formRef = useRef<HTMLFormElement>(null);
// Reset the form after a successful submission
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h2 className="text-base font-semibold text-slate-900 mb-4">
Neuen Ort anlegen
</h2>
<form ref={formRef} action={formAction} className="space-y-4">
{/* Name field */}
<div>
<label
htmlFor="loc-name"
className="block text-sm font-medium text-slate-700 mb-1"
>
Name <span className="text-red-500">*</span>
</label>
<input
id="loc-name"
name="name"
type="text"
placeholder="z. B. Serverraum, Wohnzimmer"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Description field */}
<div>
<label
htmlFor="loc-description"
className="block text-sm font-medium text-slate-700 mb-1"
>
Beschreibung{" "}
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
</label>
<textarea
id="loc-description"
name="description"
rows={3}
placeholder="Kurze Beschreibung des Ortes…"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 resize-y
"
/>
</div>
{/* Error / success feedback */}
{state.error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{state.error}
</p>
)}
{state.success && (
<p className="text-sm text-green-700 bg-green-50 border border-green-100 px-3 py-2 rounded-lg">
Ort erfolgreich angelegt.
</p>
)}
<SubmitButton />
</form>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import ReactMarkdown from "react-markdown";
interface MarkdownContentProps {
content: string;
/** Use 'compact' for small spaces like core notes, 'full' for large areas */
size?: "compact" | "full";
}
export default function MarkdownContent({
content,
size = "full",
}: MarkdownContentProps) {
return (
<div
className={`
prose max-w-none text-slate-700
${size === "compact" ? "prose-xs" : "prose-sm"}
prose-headings:text-slate-900
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-code:text-slate-800 prose-code:font-mono prose-code:text-xs
prose-pre:bg-slate-100 prose-pre:border prose-pre:border-slate-200
`}
>
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}

17
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
// Prevent multiple PrismaClient instances in development due to hot-reload.
// In production, a new client is created once per process.
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` })
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

25
src/lib/types.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { Core, Cable, Location } from "@/generated/prisma/browser";
// Location enriched with the count of cables that reference it
export type LocationWithCounts = Location & {
_count: {
startCables: number;
endCables: number;
};
};
// Cable with its location names and core count (used in the overview table)
export type CableWithLocations = Cable & {
startLocation: Location;
endLocation: Location;
_count: {
cores: number;
};
};
// Cable with full detail: locations + all cores (used on the detail page)
export type CableWithDetails = Cable & {
startLocation: Location;
endLocation: Location;
cores: Core[];
};

20
tailwind.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
},
},
},
plugins: [typography],
};
export default config;