This commit is contained in:
Linrador 2025-10-23 12:11:41 +02:00
commit 9a0fff5385
92 changed files with 34448 additions and 0 deletions

9
backend/.env Normal file
View File

@ -0,0 +1,9 @@
DATABASE_URL="file:./dev.db"
JWT_SECRET=tegvideo7010!
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=seduesseldorf@gmail.com
SMTP_PASS=odkxssbmfvewpitv
MAIL_FROM="'SE Düsseldorf' <seduesseldorf@gmail.com>"
UNSUBSCRIBE_SECRET=tegvideo7010!
FRONTEND_ORIGIN=https://sekt.tegdssd.de

23
backend/certs/myRoot.crt Normal file
View File

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID0TCCArmgAwIBAgIUXtS3/4RCGrDOM9uBOowzcH6ObTEwDQYJKoZIhvcNAQEL
BQAweDELMAkGA1UEBhMCREUxDDAKBgNVBAgMA05SVzEKMAgGA1UEBwwBRDENMAsG
A1UECgwEU0UtRDEMMAoGA1UECwwDVEVHMRMwEQYDVQQDDAp0ZWdkc3NkLmRlMR0w
GwYJKoZIhvcNAQkBFg50ZWdAdGVnZHNzZC5kZTAeFw0yNTAxMTYwODUxMjhaFw0z
NTAxMTQwODUxMjhaMHgxCzAJBgNVBAYTAkRFMQwwCgYDVQQIDANOUlcxCjAIBgNV
BAcMAUQxDTALBgNVBAoMBFNFLUQxDDAKBgNVBAsMA1RFRzETMBEGA1UEAwwKdGVn
ZHNzZC5kZTEdMBsGCSqGSIb3DQEJARYOdGVnQHRlZ2Rzc2QuZGUwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCf2ReWVqa9RD9xASRO1g7TL3nKlrwVG8Cl
POI2KyZfi+YnSYnGK4jtmVLVIgRalPDc9hL1KknieTuvn972eXTKM9mkF0fI5iEg
fuopPvlZeW01zIIMQr5IK3RLtw4kQ28nRLux5qXJGpxTLd0jQSz1AQ2o9EHkxXT3
4OBIbhHsi697ykIiNGqCjrzVKTEKVHsMQBiCtC4C30hSKuRGvNFOt0PVhw/qs9WL
CmHKJiGffZmtCcvYSHBmYl4dNV0RkkIeZWDDXIiZj9qlcRKUJEtfKuuThu3z8qYC
Q+CQ6qf9SaPWGBUA5GsVty7ipVBgrkLjDKOqEsOhfXfRBUpFMGeVAgMBAAGjUzBR
MB0GA1UdDgQWBBTStDq2DsHZkyAxQgMTaiB18Rmn4DAfBgNVHSMEGDAWgBTStDq2
DsHZkyAxQgMTaiB18Rmn4DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA
A4IBAQCGMzirjIP+evX2Zvl20yrcCqqmXFAWKoS2iPxBlxAQS6NpqaGoZJ/4F3cZ
GZOlPNn24K4T4zY/DgEeHfW0CiSlhs1KJumo51xd/gC9+GGmZVuSrwizmQV6pnjF
G3Py5s0w6aF66na++HZ0KtCtL71PQhZretZh8s9km5bQyJEctfmNoCUFkuMSmh3y
/x5Y0pZLC2Y1Aha0Qsk1XDlYFbxP0/422DH2NAA6vqkLR/FJ/mcXWL9BIHYqeyKN
WqU2H7kKkRJcS2IznZlX21AM4UirrFJArtultBsZww0SKZVYTYlPiHyMfeDappjh
CMi0uNfy5n+2HyLXNtDZcnMyNTGh
-----END CERTIFICATE-----

27
backend/certs/myRoot.key Normal file
View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAn9kXllamvUQ/cQEkTtYO0y95ypa8FRvApTziNismX4vmJ0mJ
xiuI7ZlS1SIEWpTw3PYS9SpJ4nk7r5/e9nl0yjPZpBdHyOYhIH7qKT75WXltNcyC
DEK+SCt0S7cOJENvJ0S7sealyRqcUy3dI0Es9QENqPRB5MV09+DgSG4R7Iuve8pC
IjRqgo681SkxClR7DEAYgrQuAt9IUirkRrzRTrdD1YcP6rPViwphyiYhn32ZrQnL
2EhwZmJeHTVdEZJCHmVgw1yImY/apXESlCRLXyrrk4bt8/KmAkPgkOqn/Umj1hgV
AORrFbcu4qVQYK5C4wyjqhLDoX130QVKRTBnlQIDAQABAoIBABB7rGj+Sndd4ETg
30OZ4zF3xdFUNdTfZcAkRRdc37oejP8kICblOlt14grEVTqZ/TRr04ygbboC0lrL
24wC/j4Tlq/LDsXypRvaun7CVagaU16m1go5n7GFeZqNJ9Ipef7MoaY4qIPSUKhr
JFCMWfxxvVzcwya6DdO5xqbxYrvHifUVqJnjLrYNQUMmWJodjTZIWg9dbDYkWKIk
eoK2cnz7NlEujpCxgsMlibDbzIkd8G4rFXEcCZw4Hz1fLXEQvCv8orxiAjt3AkeQ
JIf79LaJNE2JdxInnTR/7Gaewk7+b6E75O6r0bPO4nGN7/pqlvaui85GJb5LBWGI
XbQ7SMECgYEA0PPFQmbdm4pbwdJ4+1hDE4oDNA4YvlR/1LbtY1r8nAShzaR0DBMr
GmLG6gS6xC/7BV2JEShSAAGsHyV/QVqBBhpfeP77DltxcHXXEzRf0DsrBdm3KsD/
o7Q4R8Fro/08t0olS9iAwUXu05zC8XyFRyRXoRDvVTyNttmDXvO3HXkCgYEAw9bn
AlMsqX8OkKAPmoR0Em8pj2Kr0SCtOteUmvAb1bMGkCnEnGEdeP36GTSBgq0CyTA7
RSxO03r+/3+JdB7HZTPzKiv+vHqHqHDRErBIvDOcFkLfuarayhXJf4+pGqmT6Is1
czjggUsGeM22EUwo7lzXT9gsmQgZXAfhthxTv/0CgYEAxteT2jLFpKSv2OyP0LAX
dNqbXcgkZ8tFsok5spj9VwNZbraW8H61P/DL1hsWGGiIenyeWUODdRoiT6mBRhH4
QnSKcjxDcVA3zYt2VoI6w4/qyzB6DCeJnqF8BVzdMDtmsg3PHDQ1orcIJTxCj0eu
FRtSgKX6+6QaP+0SBSPsGBECgYAi9la/f4HVsK00/J5Mg6EFOXs98euipibG/n21
O3B8sj1Vt182W9AbLZxcq5cDcUeyCz5JlyNrdeXYTziG1ofadW/P85LCq01UsO9i
Wr5hewU+pCm0x9/PfBxA/bC+5c9WEKQ2Mc1Cx9Yb8v2yENqt0z1NL9ama1+7olyV
WnFJMQKBgQDGHRjD9Lfd1jx2ct2CYNlsx6ioYzfACZGaD0gMEMoOkV0NzDHtQbjp
nkJHOVMGxbPxG+TlCmCEbunScgpZ95rUsVEiREHrqFmuBRiEqmXqPqvw2iMd9yMb
CEVCUyOB+FcUKb/kw8fcoK/3maY5wx+2fP6Iu9AFPVz2p7WXNOSTpA==
-----END RSA PRIVATE KEY-----

1767
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
backend/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"dependencies": {
"@prisma/client": "^6.11.0",
"bcrypt": "^6.0.0",
"chokidar": "^4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"fast-xml-parser": "^5.2.3",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3",
"sharp": "^0.34.2",
"ws": "^8.18.2"
},
"devDependencies": {
"prisma": "^6.11.0"
}
}

BIN
backend/prisma/dev.db Normal file

Binary file not shown.

View File

@ -0,0 +1,84 @@
-- CreateTable
CREATE TABLE "Recognition" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"license" TEXT NOT NULL,
"licenseFormatted" TEXT,
"country" TEXT,
"confidence" INTEGER,
"timestampUTC" DATETIME NOT NULL,
"timestampLocal" DATETIME NOT NULL,
"cameraName" TEXT,
"classification" TEXT,
"imageFile" TEXT,
"plateFile" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"brand" TEXT,
"model" TEXT
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" DATETIME,
"lastLogin" DATETIME
);
-- CreateTable
CREATE TABLE "CameraAccess" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" TEXT NOT NULL,
"camera" TEXT NOT NULL,
"from" DATETIME,
"to" DATETIME,
CONSTRAINT "CameraAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "NotificationRule" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"plates" TEXT,
"brand" TEXT,
"model" TEXT,
"camera" TEXT,
"timeFrom" TEXT,
"timeTo" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "NotificationRule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "NotificationRecipient" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"ruleId" INTEGER NOT NULL,
"email" TEXT NOT NULL,
CONSTRAINT "NotificationRecipient_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "NotificationRule" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "UnsubscribeToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"ruleId" INTEGER,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "UnsubscribeToken_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "NotificationRule" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Recognition_license_timestampUTC_key" ON "Recognition"("license", "timestampUTC");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "UnsubscribeToken_token_key" ON "UnsubscribeToken"("token");
-- CreateIndex
CREATE INDEX "UnsubscribeToken_ruleId_email_idx" ON "UnsubscribeToken"("ruleId", "email");

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Recognition" ADD COLUMN "brandmodelconfidence" INTEGER;
ALTER TABLE "Recognition" ADD COLUMN "direction" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Recognition" ADD COLUMN "directionDegrees" INTEGER;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@ -0,0 +1,109 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // oder postgres / mysql …
url = env("DATABASE_URL")
}
/**
* ───────────── Bestehende Tabellen ─────────────
*/
model Recognition {
id Int @id @default(autoincrement())
license String
licenseFormatted String?
country String?
confidence Int?
timestampUTC DateTime
timestampLocal DateTime
cameraName String?
classification String?
direction String?
directionDegrees Int?
imageFile String?
plateFile String?
createdAt DateTime @default(now())
brand String?
model String?
brandmodelconfidence Int?
@@unique([license, timestampUTC])
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
isAdmin Boolean @default(false)
expiresAt DateTime?
lastLogin DateTime?
cameraAccess CameraAccess[]
notificationRules NotificationRule[] @relation("UserRules")
}
model CameraAccess {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId String
camera String
from DateTime?
to DateTime?
}
/**
* ───────────── Neue Tabellen für Benachrichtigungen ─────────────
*/
model NotificationRule {
id Int @id @default(autoincrement())
user User @relation("UserRules", fields: [userId], references: [id])
userId String
enabled Boolean @default(true)
/**
* optionale Filter --------------
*/
plates String?
brand String?
model String?
camera String?
timeFrom String?
timeTo String?
/**
* Empfänger ---------------------
*/
recipients NotificationRecipient[] @relation("RuleRecipients")
/**
* Unsubscribe Tokens -------
*/
unsubscribeTokens UnsubscribeToken[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NotificationRecipient {
id Int @id @default(autoincrement())
ruleId Int
email String
rule NotificationRule @relation("RuleRecipients", fields: [ruleId], references: [id], onDelete: Cascade)
}
model UnsubscribeToken {
id String @id @default(uuid())
ruleId Int?
email String
token String @unique
createdAt DateTime @default(now())
expiresAt DateTime
rule NotificationRule? @relation(fields: [ruleId], references: [id], onDelete: Cascade)
@@index([ruleId, email])
}

35
backend/prisma/seed.js Normal file
View File

@ -0,0 +1,35 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const prisma = new PrismaClient();
async function main() {
const username = 'admin';
const password = 'tegvideo7010!'; // Wähle ein sicheres Passwort
const hashed = await bcrypt.hash(password, 10);
const existing = await prisma.user.findUnique({ where: { username } });
if (!existing) {
const user = await prisma.user.create({
data: {
username,
passwordHash: hashed,
isAdmin: true,
expiresAt: null,
},
});
console.log('✅ Admin-User erstellt:', user.username);
} else {
console.log('⚠️ Benutzer existiert bereits.');
}
}
main()
.catch((e) => {
console.error('❌ Fehler beim Seed:', e);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,180 @@
#!/usr/bin/env node
/*
Backfill direction & directionDegrees from *_Info.xml files into Recognition rows.
Usage:
node scripts/backfill-direction.js [PATH_TO_ROOT]
*/
const fs = require('fs');
const path = require('path');
const { PrismaClient } = require('@prisma/client');
const { XMLParser } = require('fast-xml-parser');
const prisma = new PrismaClient();
const parser = new XMLParser({ ignoreAttributes: false });
const ROOT = process.argv[2] || './data';
const CONCURRENCY = 8; // parallel verarbeitete Dateien
// Hilfsfunktion: alle Dateien rekursiv einsammeln
function walkFiles(dir, list = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walkFiles(full, list);
} else if (e.isFile() && e.name.endsWith('_Info.xml')) {
list.push(full);
}
}
return list;
}
// XML -> Date aus {DateUTC, TimeUTC} / {DateLocal, TimeLocal}
function toDate(dateObj, timeObj) {
if (!dateObj || !timeObj) return null;
const y = Number(dateObj.Year);
const m = Number(dateObj.Month) - 1; // 0-based
const d = Number(dateObj.Day);
const hh = Number(timeObj.Hour);
const mm = Number(timeObj.Min);
const ss = Number(timeObj.Sec);
const ms = Number(timeObj.Msec || 0);
if ([y,m,d,hh,mm,ss].some(n => Number.isNaN(n))) return null;
return new Date(y, m, d, hh, mm, ss, ms);
}
async function processXmlFile(file) {
try {
const xml = fs.readFileSync(file, 'utf8');
const json = parser.parse(xml);
const data = json?.ReturnRecognitionData?.StandardMessage?.RecognitionData;
if (!data) return { skipped: true, reason: 'no RecognitionData' };
const license = String(data.License || '').trim();
if (!license) return { skipped: true, reason: 'no license' };
const dirStr = data.Direction != null ? String(data.Direction).trim() : null;
const degInt = data.DirectionDegrees != null ? parseInt(data.DirectionDegrees, 10) : null;
// nichts zu backfillen?
if ((!dirStr || dirStr.length === 0) && (!degInt || Number.isNaN(degInt))) {
return { skipped: true, reason: 'no direction fields in XML' };
}
// Zeiten
const tsUTC = toDate(data.DateUTC, data.TimeUTC);
const tsLocal = toDate(data.DateLocal, data.TimeLocal);
if (!tsUTC && !tsLocal) {
return { skipped: true, reason: 'no timestamps' };
}
// Update-Bedingungen:
// - entweder (license, timestampUTC) matcht
// - oder (license, timestampLocal) matcht
// - und direction/directionDegrees sind leer / null / 0
const where = {
AND: [
{
OR: [
...(tsUTC ? [{ license, timestampUTC: tsUTC }] : []),
...(tsLocal ? [{ license, timestampLocal: tsLocal }] : []),
]
},
{
OR: [
{ direction: null },
{ direction: '' },
{ directionDegrees: null },
{ directionDegrees: 0 },
]
}
]
};
// Nur setzen, wenn sie in XML sinnvolle Werte haben
const dataUpdate = {};
if (dirStr && dirStr.length > 0) dataUpdate.direction = dirStr;
if (Number.isInteger(degInt) && degInt >= 0) dataUpdate.directionDegrees = degInt;
if (Object.keys(dataUpdate).length === 0) {
return { skipped: true, reason: 'nothing to update' };
}
const result = await prisma.recognition.updateMany({
where,
data: dataUpdate,
});
return { updated: result.count };
} catch (err) {
return { error: err.message || String(err) };
}
}
// Einfache Parallelitätssteuerung
async function run() {
console.log(`🔎 Suche XMLs unter: ${path.resolve(ROOT)}`);
const files = walkFiles(ROOT);
console.log(`Gefundene *_Info.xml: ${files.length}`);
let idx = 0;
let active = 0;
let done = 0;
let updated = 0;
let skipped = 0;
let errors = 0;
await new Promise((resolve) => {
const pump = () => {
while (active < CONCURRENCY && idx < files.length) {
const file = files[idx++];
active++;
processXmlFile(file)
.then((res) => {
done++;
if (res?.updated) {
updated += res.updated;
console.log(`${file} → updated ${res.updated}`);
} else if (res?.skipped) {
skipped++;
// optional leiser:
// console.log(`⏭️ ${file} → skipped: ${res.reason}`);
} else if (res?.error) {
errors++;
console.warn(`${file}${res.error}`);
} else {
// nichts
}
})
.catch((e) => {
errors++;
console.warn(`${file}${e.message || e}`);
})
.finally(() => {
active--;
if (done === files.length) resolve();
else pump();
});
}
};
pump();
});
console.log('—'.repeat(50));
console.log(`Fertig. Dateien: ${files.length}`);
console.log(`✅ Updates: ${updated}`);
console.log(`⏭️ Skipped: ${skipped}`);
console.log(`❌ Errors : ${errors}`);
await prisma.$disconnect();
}
run().catch(async (e) => {
console.error('Fatal:', e);
await prisma.$disconnect();
process.exit(1);
});

1609
backend/server.js Normal file

File diff suppressed because it is too large Load Diff

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,16 @@
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"),
];
export default eslintConfig;

21
frontend/global.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
import type { IStaticMethods } from "preline/dist";
declare global {
interface Window {
// Optional third-party libraries
_;
$: typeof import("jquery");
jQuery: typeof import("jquery");
DataTable;
Dropzone;
VanillaCalendarPro;
// Preline UI
HSStaticMethods: IStaticMethods;
// ApexCharts helper (Preline)
buildTooltip?: (...args: any[]) => string;
}
}
export {};

46
frontend/next.config.ts Normal file
View File

@ -0,0 +1,46 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
'http://localhost:3000',
'http://10.0.1.25:3000',
'kennzeichen.local',
'sekt.local'
],
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '3001',
pathname: '/images/**',
},
{
protocol: 'http',
hostname: '10.0.1.25',
port: '3001',
pathname: '/images/**',
},
{
protocol: 'http',
hostname: '10.0.3.6',
port: '3001',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: '10.0.3.6',
port: '3001',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: 'kennzeichen.tegdssd.de',
port: '3001',
pathname: '/images/**',
},
],
},
};
export default nextConfig;

6915
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
frontend/package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "kennzeichenerfassung",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"tauri": "tauri"
},
"dependencies": {
"@preline/datatable": "^3.0.0",
"@preline/datepicker": "^3.1.0",
"@preline/file-upload": "^3.1.0",
"@preline/select": "^3.1.0",
"@preline/theme-switch": "^3.1.0",
"bcrypt": "^6.0.0",
"chart.js": "^4.5.0",
"chartjs-plugin-datalabels": "^2.2.0",
"clipboard": "^2.0.11",
"clsx": "^2.1.1",
"datatables.net": "^2.3.1",
"datatables.net-dt": "^2.3.1",
"date-fns": "^4.1.0",
"dropzone": "^6.0.0-beta.2",
"framer-motion": "^12.23.0",
"https": "^1.0.0",
"jquery": "^3.7.1",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.513.0",
"next": "15.3.3",
"nouislider": "^15.8.1",
"postcss": "^8.5.4",
"preline": "^3.0.1",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"undici": "^6.21.3",
"vanilla-calendar-pro": "^3.0.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.8",
"@tauri-apps/cli": "^2.6.1",
"@types/bcrypt": "^5.0.2",
"@types/datatables.net": "^1.10.28",
"@types/jquery": "^3.5.32",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.17",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tailwindcss": "^4.1.8",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
// /app/(auth)/login/layout.tsx
import PageTransition from "@/app/providers/PageTransition"
export const metadata = {
title: "Kennzeichenerfassung - TEG Düsseldorf",
description: "",
}
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<PageTransition>
{children}
</PageTransition>
)
}

View File

@ -0,0 +1,146 @@
// /app/(auth)/login/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useFadeNavigate } from '@/app/providers/PageTransition';
import LoginForm from '@/app/components/LoginForm';
import { useCurrentUser } from '@/app/components/AuthContext';
import Image from 'next/image';
import Alert from '@/app/components/Alert';
import {
readLogoutNotice,
mapLogoutReasonToAlert,
LogoutNoticePayload,
} from '@/lib/logoutNotice';
export default function LoginPage() {
const { login } = useCurrentUser();
const fadeNavigate = useFadeNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// UI-State für Logout-Notice
const [logoutNotice, setLogoutNotice] = useState<LogoutNoticePayload | null>(null);
// Beim Mount: Notice aus localStorage holen
useEffect(() => {
const payload = readLogoutNotice(true); // clear nach dem Lesen
if (payload) {
setLogoutNotice(payload);
}
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const res = await fetch(`/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await res.json();
const token = data?.token;
let tokenExpiresAt: number | undefined;
if (token) {
const payloadBase64 = token.split('.')[1];
const decodedPayload = JSON.parse(atob(payloadBase64));
if (decodedPayload?.exp) {
tokenExpiresAt = decodedPayload.exp * 1000;
}
}
setIsLoading(false);
if (!res.ok) {
setError(data.error || 'Login fehlgeschlagen');
return;
}
const user = data?.user;
if (!user || typeof user.isAdmin !== 'boolean') {
setError('Login erfolgreich, aber Benutzerinformationen fehlen.');
return;
}
login({ ...user, tokenExpiresAt });
fadeNavigate(user.isAdmin ? '/admin' : '/');
} catch (err) {
setIsLoading(false);
setError('Serverfehler beim Login');
console.error('Login-Fehler:', err);
}
};
// Auto-dismiss für Logout-Notice (optional)
useEffect(() => {
if (!logoutNotice) return;
const t = setTimeout(() => setLogoutNotice(null), 12_000);
return () => clearTimeout(t);
}, [logoutNotice]);
// Alert-Mapping
const mapped =
logoutNotice ? mapLogoutReasonToAlert(logoutNotice.reason) : null;
return (
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm space-y-8 bg-white dark:bg-neutral-800 p-6 rounded-lg shadow">
<Image
className="mx-auto"
src="/assets/img/logo.png"
alt="TEG Düsseldorf"
width={200}
height={200}
unoptimized
/>
<h1 className="text-xl font-bold text-center text-gray-800 dark:text-white mb-1">
SEKT
</h1>
<h2 className="text-lg font-bold text-center text-gray-500 dark:text-neutral-500 mb-4">
SE Kennzeichenerfassungstool
</h2>
{/* Logout-Grund Alert */}
{logoutNotice && mapped ? (
<div className="relative">
<Alert
title={mapped.title}
message={logoutNotice.message}
type={mapped.type}
color={mapped.color}
/>
</div>
) : null}
{/* Login-Fehler Alert */}
{error ? (
<Alert
title="Fehler"
message={error}
type="soft"
color="danger"
/>
) : null}
<LoginForm
username={username}
password={password}
error={null /* Alert übernimmt Anzeige */}
isLoading={isLoading}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
'use client';
import UserForm from '../../components/UserForm';
import UserTable from '../../components/UserTable';
export default function Administration() {
return (
<div className="space-y-6">
<UserForm onUserCreated={() => {}} />
<UserTable />
</div>
);
}

View File

@ -0,0 +1,36 @@
// app/(protected)/layout.tsx
import { redirect } from 'next/navigation';
import { getServerUser } from '@/lib/auth';
import Header from '../components/Header';
import Tabs from '../components/Tabs';
import ThemeProvider from '../components/ThemeProvider';
import Card from '../components/Card';
import PageTransition from '../providers/PageTransition';
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getServerUser();
if (!user) {
redirect('/login');
}
return (
<>
<ThemeProvider />
<PageTransition>
<Header />
<main className="p-4 flex-1 flex flex-col">
<Tabs isAdmin={user.isAdmin} />
<Card className="mt-4 flex-grow flex flex-col">
{children}
</Card>
</main>
</PageTransition>
</>
);
}

View File

@ -0,0 +1,604 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Button } from '../../components/Button';
import Modal from '../../components/Modal';
import Table from '../../components/Table';
import Alert from '../../components/Alert';
import LoadingSpinner from '../../components/LoadingSpinner';
import TimePicker from '../../components/TimePicker';
import ComboBox from '../../components/ComboBox';
type Rule = {
id: string;
licensePattern?: string | null;
brand?: string | null;
model?: string | null;
timeFrom?: string | null;
timeTo?: string | null;
recipients: string[];
};
/* ------------------------------------------------------------- */
/* Alle Marken als String-Array
/* ------------------------------------------------------------- */
const BRAND_OPTIONS: string[] = [
"Abarth",
"AC",
"Acura",
"Aiways",
"Aixam",
"Alfa Romeo",
"ALPINA",
"Alpine",
"Alvis",
"Ariel",
"Artega",
"Asia Motors",
"Aston Martin",
"Audi",
"Austin",
"Austin Healey",
"Auto Union",
"BAIC",
"Barkas",
"Bentley",
"Bizzarrini",
"BMW",
"Borgward",
"Brilliance",
"Bugatti",
"Buick",
"BYD",
"Cadillac",
"Casalini",
"Caterham",
"Cenntro",
"Chatenet",
"Chevrolet",
"Chrysler",
"Citroën",
"Cobra",
"Corvette",
"Cupra",
"Dacia",
"Daewoo",
"Daihatsu",
"Datsun",
"Delahaye",
"DeLorean",
"DeTomaso",
"DFSK",
"Dodge",
"Donkervoort",
"DS Automobiles",
"e.GO",
"Elaris",
"Estrima",
"Facel Vega",
"Ferrari",
"Fiat",
"Fisker",
"Ford",
"GAC Gonow",
"Gemballa",
"Genesis",
"GMC",
"Grecav",
"GWM",
"Hamann",
"Heinkel",
"Holden",
"Honda",
"Hongqi",
"Horch",
"Hummer",
"Hyundai",
"INEOS",
"Infiniti",
"Invicta",
"Isuzu",
"Iveco",
"JAC",
"Jaguar",
"Jeep",
"Jiayuan",
"KGM",
"Kia",
"Koenigsegg",
"KTM",
"Lada",
"Lamborghini",
"Lancia",
"Land Rover",
"Landwind",
"Leapmotor",
"LEVC",
"Lexus",
"Ligier",
"Lincoln",
"Lotus",
"Lucid",
"Lynk&Co",
"Mahindra",
"MAN",
"Maserati",
"Maxus",
"Maybach",
"Mazda",
"McLaren",
"Mercedes-Benz",
"Messerschmitt",
"MG",
"Microcar",
"Microlino",
"MINI",
"Mitsubishi",
"Morgan",
"NIO",
"Nissan",
"NSU",
"Oldsmobile",
"Opel",
"ORA",
"Packard",
"Pagani",
"Peugeot",
"Piaggio",
"Plymouth",
"Polestar",
"Pontiac",
"Porsche",
"Proton",
"Renault",
"Riley",
"Rolls-Royce",
"Rover",
"Ruf",
"Saab",
"Santana",
"Seat",
"Seres",
"Silence",
"Simca",
"Skoda",
"Smart",
"speedART",
"Spyker",
"Ssangyong",
"Studebaker",
"Subaru",
"Suzuki",
"SWM",
"Talbot",
"Tata",
"TECHART",
"Tesla",
"Toyota",
"Trabant",
"Triumph",
"TVR",
"TYN-e",
"Vincent",
"VinFast",
"Volkswagen",
"Volvo",
"Wartburg",
"Westfield",
"WEY",
"Wiesmann",
"XEV",
"XPENG",
"Zeekr",
"Zhidou",
"Andere",
];
/* --------------------------------------------------------- */
/* Haupt-Komponente */
/* --------------------------------------------------------- */
export default function NotiticationsPage() {
/* ---------------- State ---------------- */
const [rules, setRules] = useState<Rule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/* Modal-States */
const [newModalOpen, setNewModalOpen] = useState(false);
const [editRule, setEditRule] = useState<Rule | null>(null);
/* Formular-State (wird für beide Modale genutzt) */
const [form, setForm] = useState<Omit<Rule, 'id'>>({
licensePattern: '',
brand: '',
model: '',
timeFrom: '',
timeTo: '',
recipients: [''],
});
/* ------------- Regeln laden --------------- */
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/notifications`, { credentials: 'include' });
const data = await res.json();
const parsed = Array.isArray(data)
? data.map((r) => {
const rule = r as Rule;
return {
...rule,
timeFrom: rule.timeFrom,
timeTo : rule.timeTo
};
}) : [];
setRules(parsed);
} catch (e) {
console.error(e);
setError(
'Benachrichtigungsregeln konnten nicht geladen werden.'
);
} finally {
setLoading(false);
}
})();
}, []);
/* ------------- Helper -------------------- */
const resetForm = () =>
setForm({
licensePattern: '',
brand: '',
model: '',
timeFrom: '',
timeTo: '',
recipients: [''],
});
const closeModals = () => {
setNewModalOpen(false);
setEditRule(null);
resetForm();
};
/* ------------- Empfänger-Felder ---------- */
const addRecipient = () =>
setForm(f => ({ ...f, recipients: [...f.recipients, ''] }));
const changeRecipient = (idx: number, val: string) =>
setForm(f => ({
...f,
recipients: f.recipients.map((r, i) =>
i === idx ? val : r
),
}));
const removeRecipient = (idx: number) =>
setForm(f => ({
...f,
recipients: f.recipients.filter((_, i) => i !== idx),
}));
/* ------------- Speichern (neu / edit) ---- */
const handleSave = async () => {
const recipients = form.recipients
.map(r => r.trim())
.filter(Boolean);
if (recipients.length === 0) {
alert('Mindestens eine E-Mail-Adresse angeben.');
return;
}
const body = {
plates: form.licensePattern || null,
brand: form.brand || null,
model: form.model || null,
camera: null,
timeFrom: form.timeFrom ?? null,
timeTo: form.timeTo ?? null,
emails: recipients,
};
try {
const res = await fetch(
editRule ? `/api/notifications/${editRule.id}` : `/api/notifications`,
{
method: editRule ? 'PUT' : 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) throw new Error(await res.text());
const saved: Rule = await res.json();
setRules(r =>
editRule
? r.map(x => (x.id === saved.id ? saved : x))
: [...r, saved]
);
closeModals();
} catch (err) {
console.error('❌ Fehler beim Speichern:', err);
alert('Konnte Regel nicht speichern.');
}
};
/* ------------- Löschen ------------------- */
const handleDelete = async (id: string) => {
if (!confirm('Regel wirklich löschen?')) return;
await fetch(`/api/notifications/${id}`, {
method: 'DELETE',
credentials: 'include',
});
setRules(r => r.filter(rule => rule.id !== id));
};
const handleFromChange = useCallback(
(val: string | null) =>
setForm(f => ({ ...f, timeFrom: val ?? '' })),
[]
);
const handleToChange = useCallback(
(val: string | null) =>
setForm(f => ({ ...f, timeTo: val ?? '' })),
[]
);
/* ------------- Formular-UI --------------- */
const renderForm = () => (
<div className="space-y-4">
{/* Empfänger ------------------------------------------------ */}
<div>
<label className="font-medium mb-1 block">
Empfänger-Adresse(n)
</label>
{form.recipients.map((mail, idx) => (
<div key={idx} className="flex gap-2 mb-2">
<input
className="flex-1 border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
placeholder="user@example.com"
value={mail}
onChange={e => changeRecipient(idx, e.target.value)}
type='email'
/>
{idx === 0 ? (
<Button
size="small"
variant="ghost"
onClick={addRecipient}
className='min-w-[100px] justify-center'
>
Hinzufügen
</Button>
) : (
<Button
size="small"
variant="ghost"
color="red"
onClick={() => removeRecipient(idx)}
className='min-w-[100px] justify-center'
>
Entfernen
</Button>
)}
</div>
))}
</div>
{/* Filter --------------------------------------------------- */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block mb-1">
Kennzeichen enthält
</label>
<input
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
value={form.licensePattern ?? ""}
onChange={e =>
setForm(f => ({
...f,
licensePattern: e.target.value,
}))
}
/>
</div>
<div>
<label className="block mb-1">Marke</label>
<ComboBox
id='input-rules-brand'
items={BRAND_OPTIONS.sort()}
value={form.brand ?? ''} // controlled value
onChange={val =>
setForm(f => ({ ...f, brand: val }))
}
placeholder="Marke suchen..."
/>
</div>
<div>
<label className="block mb-1">Modell</label>
<input
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
value={form.model ?? ""}
onChange={e =>
setForm(f => ({ ...f, model: e.target.value }))
}
/>
</div>
<div>
<label className="block mb-1">Uhrzeit (von bis)</label>
<div className="flex gap-2">
<TimePicker
id="rules-from"
value={form.timeFrom ?? ""}
onChange={handleFromChange}
/>
<TimePicker
id="rules-to"
value={form.timeTo ?? ""}
onChange={handleToChange}
/>
</div>
</div>
</div>
<Alert
title="Hinweis"
type="soft"
color="info"
message="Alle Filter sind optional. Wenn Sie keinen Filter setzen, erhalten Sie eine E-Mail zu jedem neu erfassten Kennzeichen. Alle Filter sind miteinander als UND-Beziehung veknüpft."
/>
</div>
);
/* ------------- Render ------------------- */
if (loading) return <LoadingSpinner showBackground={true} />;
if (error)
return (
<Alert
title="Fehler"
type="soft"
color="danger"
message={error}
/>
);
return (
<div>
<div className="mb-4">
<Button
color="blue"
onClick={() => setNewModalOpen(true)}
className='w-full sm:w-auto'
>
+ Neue Regel
</Button>
</div>
{rules.length === 0 ? (
<Alert
title="Hinweis"
type="soft"
color="info"
message="Noch keine Benachrichtigungsregeln angelegt."
/>
) : (
<Table>
<Table.Head>
<Table.Row>
<Table.Cell>Filter ID</Table.Cell>
<Table.Cell>Filter</Table.Cell>
<Table.Cell>Empfänger</Table.Cell>
<Table.Cell>Aktionen</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{rules.map(r => (
<Table.Row key={r.id}>
<Table.Cell>
{r.id}
</Table.Cell>
<Table.Cell>
<div className="text-sm leading-6">
{r.licensePattern && (
<div>
Kennzeichen enthält:{' '}
<b>{r.licensePattern}</b>
</div>
)}
{r.brand && (
<div>
Marke: <b>{r.brand}</b>
</div>
)}
{r.model && (
<div>
Modell: <b>{r.model}</b>
</div>
)}
{r.timeFrom && r.timeTo && (
<div>
Uhrzeit: <b>{r.timeFrom} {r.timeTo} Uhr</b>
</div>
)}
{!(
r.licensePattern ||
r.brand ||
r.model ||
r.timeFrom
) && ' Keine '}
</div>
</Table.Cell>
<Table.Cell>{r.recipients.join(', ')}</Table.Cell>
<Table.Cell>
<div className='flex gap-2'>
{/* ✏️ Bearbeiten */}
<Button
size="small"
color="yellow"
variant="solid"
onClick={() => {
setEditRule(r);
// Formular vorbelegen
setForm({
licensePattern: r.licensePattern ?? '',
brand: r.brand ?? '',
model: r.model ?? '',
timeFrom: r.timeFrom,
timeTo: r.timeTo,
recipients:
r.recipients.length > 0
? [...r.recipients]
: [''],
});
}}
>
Bearbeiten
</Button>
{/* 🗑️ Löschen */}
<Button
size="small"
color="red"
variant="solid"
onClick={() => handleDelete(r.id)}
>
🗑 Löschen
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)}
{/* ---------- Modal: neue Regel ---------- */}
<Modal
open={newModalOpen}
onClose={closeModals}
title="Neue Benachrichtigungsregel"
saveButton
onSave={handleSave}
>
{renderForm()}
</Modal>
{/* ---------- Modal: Regel bearbeiten ---- */}
<Modal
open={!!editRule}
onClose={closeModals}
title={`Regel bearbeiten (ID ${editRule?.id})`}
saveButton
onSave={handleSave}
maxWidth="max-w-2xl"
>
{renderForm()}
</Modal>
</div>
);
}

View File

@ -0,0 +1,236 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Table from '@/app/components/Table';
import ChartContainer from '@/app/components/charts/ChartContainer';
import ChartBar from '@/app/components/charts/ChartBar';
import ChartPie from '@/app/components/charts/ChartPie';
import type { DayCount, PlateCount } from '@/app/components/charts/ChartBar';
import { useSSE } from '@/app/components/SSEContext';
export default function Dashboard() {
const router = useRouter();
const { onNewRecognition } = useSSE();
const [plateData, setPlateData] = useState<PlateCount[]>([]);
const [brandData, setBrandData] = useState<DayCount[]>([]);
const [lastSevenDaysData, setLastSevenDaysData] = useState<DayCount[]>([]);
const [countryData, setCountryData] = useState<DayCount[]>([]);
const [hourlyData, setHourlyData] = useState<DayCount[]>([]);
const [cameraData, setCameraData] = useState<DayCount[]>([]);
const fetchPlateData = useCallback(() => {
fetch(`api/recognitions/top10plates`, { credentials: "include" })
.then(res => res.json())
.then(({ data }) => {
if (!Array.isArray(data)) {
setPlateData([]);
return;
}
const converted: PlateCount[] = data;
converted.sort((a, b) => b.count - a.count);
setPlateData(converted);
})
.catch(console.error);
}, []);
const fetchBrandData = useCallback(() => {
fetch(`api/recognitions/top10brands`, { credentials: "include" })
.then(res => res.json())
.then(({ labels, series }) => {
if (!Array.isArray(labels) || !Array.isArray(series)) {
setBrandData([]);
return;
}
const converted: DayCount[] = labels.map((label: string, i: number) => ({
date: label,
count: series[i]
}));
converted.sort((a, b) => b.count - a.count);
setBrandData(converted);
})
.catch(console.error);
}, []);
const fetchLastSevenDays = useCallback(() => {
fetch(`/api/recognitions/counts?days=7`, { credentials: "include" })
.then(res => res.json())
.then((counts: DayCount[]) => {
if (!Array.isArray(counts)) {
setLastSevenDaysData([]);
return;
}
setLastSevenDaysData(counts);
})
.catch((err) => {
console.error('❌ Fehler bei /counts:', err);
setLastSevenDaysData([]);
});
}, []);
const fetchCountryData = useCallback(() => {
fetch(`/api/recognitions/countries`, { credentials: "include" })
.then(res => res.json())
.then(({ labels, series }) => {
if (!Array.isArray(labels) || !Array.isArray(series)) {
setCountryData([]);
return;
}
const converted: DayCount[] = labels.map((label: string, i: number) => ({
date: label,
count: series[i]
}));
setCountryData(converted);
})
.catch((err) => {
console.error('❌ Fehler bei /countries:', err);
setCountryData([]);
});
}, []);
const fetchHourlyData = useCallback(() => {
fetch(`/api/recognitions/by-hour`, { credentials: "include" })
.then(res => res.json())
.then(({ labels, series }) => {
if (!Array.isArray(labels) || !Array.isArray(series)) {
setHourlyData([]);
return;
}
const converted: DayCount[] = labels.map((label: string, i: number) => ({
date: label,
count: series[i]
}));
setHourlyData(converted);
})
.catch((err) => {
console.error('❌ Fehler bei /by-hour:', err);
setHourlyData([]);
});
}, []);
const fetchCameraData = useCallback(() => {
fetch(`/api/recognitions/by-camera`, { credentials: "include" })
.then(res => res.json())
.then(({ labels, series }) => {
if (!Array.isArray(labels) || !Array.isArray(series)) {
setCameraData([]);
return;
}
const converted: DayCount[] = labels.map((label: string, i: number) => ({
date: label,
count: series[i]
}));
converted.sort((a, b) => b.count - a.count);
setCameraData(converted);
})
.catch((err) => {
console.error('❌ Fehler bei /by-camera:', err);
setCameraData([]);
});
}, []);
// Initial-Daten laden
useEffect(() => {
fetchPlateData();
fetchBrandData();
fetchLastSevenDays();
fetchCountryData();
fetchHourlyData();
fetchCameraData();
}, [fetchPlateData, fetchBrandData, fetchCameraData, fetchCountryData, fetchHourlyData, fetchLastSevenDays]);
// Bei neuer Erkennung alle Charts aktualisieren
useEffect(() => {
const update = () => {
fetchPlateData();
fetchBrandData();
fetchLastSevenDays();
fetchCountryData();
fetchHourlyData();
fetchCameraData();
};
onNewRecognition(update);
}, [fetchPlateData, fetchBrandData, fetchCameraData, fetchCountryData, fetchHourlyData, fetchLastSevenDays, onNewRecognition]);
return (
<div className="container">
<div className="flex flex-col lg:flex-row gap-4">
{/* Linke Spalte: 4 Spalten breit, 12 Zeilen hoch */}
<div className="lg:w-1/3 w-full">
<ChartContainer title="Top 10 erfasste Kennzeichen">
<Table>
<Table.Head>
<Table.Row>
<Table.Cell>Kennzeichen</Table.Cell>
<Table.Cell>Anzahl</Table.Cell>
<Table.Cell>Marke</Table.Cell>
<Table.Cell>Modell</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{plateData.map((entry) => (
<Table.Row
key={entry.plate}
className="cursor-pointer hover:bg-gray-100 dark:hover:bg-neutral-600 "
onClick={() => router.push(`/results?search=${encodeURIComponent(entry.plate)}`)}
>
<Table.Cell>{entry.plate}</Table.Cell>
<Table.Cell>{entry.count}</Table.Cell>
<Table.Cell>{entry.brand || '—'}</Table.Cell>
<Table.Cell>{entry.model || '—'}</Table.Cell>
</Table.Row>
))}
{plateData.length === 0 && (
<Table.Row>
<Table.Cell colSpan={4}>Keine Daten vorhanden.</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</ChartContainer>
</div>
{/* Rechts: jeweils 2 Spalten breit und 3 Zeilen hoch */}
<div className="grid grid-cols-2 md:grid-cols-10 gap-4 flex-1">
<div className="col-span-4">
<ChartContainer title="Erkennungen der letzten 7 Tage" height={250} >
<ChartBar data={lastSevenDaysData} />
</ChartContainer>
</div>
<div className="col-span-6">
<ChartContainer title="Stündliche Erkennungen" height={250}>
<ChartBar data={hourlyData} />
</ChartContainer>
</div>
<div className="col-span-3">
<ChartContainer title="Erfasste Länder" height={300}>
<ChartPie data={countryData} />
</ChartContainer>
</div>
<div className="col-span-4">
<ChartContainer title="Top 10 erfasste Fahrzeugmarken" height={300}>
<ChartPie data={brandData} />
</ChartContainer>
</div>
<div className="col-span-3">
<ChartContainer title="Erkennungen pro Kamera" height={300}>
<ChartPie data={cameraData} />
</ChartContainer>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import RecognitionsTable from '@/app/components/RecognitionsTable';
import { useSSE } from '@/app/components/SSEContext';
export default function ResultsPage() {
const searchParams = useSearchParams();
const { resetNewCount } = useSSE(); // aus deinem SSE-Context
const [resetNewMarkers, setResetNewMarkers] = useState(false);
/* ---------------------------------------------------------------
Beim Aufruf der Seite:
neue Treffer-Zähler zurücksetzen
Marker kurz anzeigen, damit das Blinken funktioniert
---------------------------------------------------------------- */
useEffect(() => {
fetch('/api/recognitions/reset-count', {
method: 'POST',
credentials: 'include',
});
resetNewCount(); // Zähler im Kontext zurücksetzen
setResetNewMarkers(true);
const t = setTimeout(() => setResetNewMarkers(false), 100);
return () => clearTimeout(t);
}, [resetNewCount]);
/* ---------------------------------------------------------------
Query-Parameter auslesen
---------------------------------------------------------------- */
const initialSearch = searchParams.get('search') ?? '';
const initialPage = parseInt(searchParams.get('page') ?? '1', 10);
/* ---------------------------------------------------------------
Render
---------------------------------------------------------------- */
return (
<RecognitionsTable
initialSearch={initialSearch}
initialPage={initialPage}
resetNewMarkers={resetNewMarkers}
/>
);
}

View File

@ -0,0 +1,62 @@
'use client';
import clsx from 'clsx';
type AlertType = 'solid' | 'soft';
type AlertColor =
| 'dark'
| 'secondary'
| 'info'
| 'success'
| 'danger'
| 'warning'
| 'light';
type AlertProps = {
title?: string;
message: string;
type?: AlertType;
color?: AlertColor;
};
export default function Alert({
title,
message,
type = 'solid',
color = 'info',
}: AlertProps) {
const baseClasses =
'mt-2 text-sm rounded-lg p-4 transition-all duration-200 focus:outline-none focus:ring-2';
const classMap: Record<AlertType, Record<AlertColor, string>> = {
solid: {
dark: 'bg-gray-800 text-white dark:bg-white dark:text-neutral-800',
secondary: 'bg-gray-500 text-white',
info: 'bg-blue-600 text-white dark:bg-blue-500',
success: 'bg-teal-500 text-white',
danger: 'bg-red-500 text-white',
warning: 'bg-yellow-500 text-white',
light: 'bg-white text-gray-600',
},
soft: {
dark: 'bg-gray-100 border border-gray-200 text-gray-800 dark:bg-white/10 dark:border-white/20 dark:text-white',
secondary: 'bg-gray-50 border border-gray-200 text-gray-600 dark:bg-white/10 dark:border-white/10 dark:text-neutral-400',
info: 'bg-blue-100 border border-blue-200 text-blue-800 dark:bg-blue-800/10 dark:border-blue-900 dark:text-blue-500',
success: 'bg-teal-100 border border-teal-200 text-teal-800 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500',
danger: 'bg-red-100 border border-red-200 text-red-800 dark:bg-red-800/10 dark:border-red-900 dark:text-red-500',
warning: 'bg-yellow-100 border border-yellow-200 text-yellow-800 dark:bg-yellow-800/10 dark:border-yellow-900 dark:text-yellow-500',
light: 'bg-white/10 border border-white/10 text-white',
},
};
return (
<div
className={clsx(baseClasses, classMap[type][color])}
role="alert"
tabIndex={-1}
>
{title && <div className="font-bold mb-1">{title}</div>}
<div>{message}</div>
</div>
);
}

View File

@ -0,0 +1,144 @@
// AuthContext.tsx
'use client';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useRef,
} from 'react';
type User = {
id: string;
username: string;
isAdmin: boolean;
tokenExpiresAt?: number;
lastLogin?: string | null;
};
type AuthContextType = {
user: User | null;
loading: boolean;
logout: (reason?: string) => void; // reason ignoriert (Kompatibilität)
login: (user: User) => void;
tokenExpiresAt: number | null;
};
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
logout: () => {},
login: () => {},
tokenExpiresAt: null,
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [tokenExpiresAt, setTokenExpiresAt] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
/**
* Flag: Es gab einen *expliziten* Logout-Vorgang (manuell, Timeout, etc.).
* Solange true, soll der Fallback-/Recovery-Fetch nicht erneut `logout()` triggern.
*/
const hasLoggedOutRef = useRef(false);
const logout = useCallback(async (reason?: string) => {
void reason;
hasLoggedOutRef.current = true;
// Server informieren (Fehler egal)
fetch(`/api/logout`, {
method: 'POST',
credentials: 'include',
}).catch(() => {});
setUser(null);
setTokenExpiresAt(null);
}, []);
const login = (user: User) => {
hasLoggedOutRef.current = false;
setUser(user);
setTokenExpiresAt(user.tokenExpiresAt ?? null);
};
// Initialer /api/me Check
useEffect(() => {
fetch(`/api/me`, { credentials: 'include', cache: 'no-store' })
.then(async (res) => {
if (!res.ok) {
if (res.status === 401) {
console.warn('🔒 Nicht eingeloggt.');
setUser(null);
return;
}
}
const data = await res.json();
const mapped: User = {
id: data.id,
username: data.username,
isAdmin: data.isAdmin,
tokenExpiresAt: data.tokenExpiresAt ?? null,
lastLogin: data.lastLogin ?? null,
};
setUser(mapped);
setTokenExpiresAt(mapped.tokenExpiresAt ?? null);
})
.catch((err) => {
console.error('❌ Auth-Fehler:', err);
setUser(null);
})
.finally(() => setLoading(false));
}, []);
// Fallback: user bei Bedarf nachladen (wenn nicht explizit ausgeloggt)
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/me', { credentials: 'include' });
const data = await res.json();
if (res.ok) {
login({
id: data.id,
username: data.username,
isAdmin: data.isAdmin,
tokenExpiresAt: data.tokenExpiresAt,
});
} else {
// Kein erneuter logout(), sonst würde evtl. ausgelöster Notice überschrieben.
setUser(null);
setTokenExpiresAt(null);
}
} catch {
setUser(null);
}
};
if (!user && !hasLoggedOutRef.current) {
fetchUser();
}
}, [user]);
return (
<AuthContext.Provider
value={{
user,
loading,
logout,
login,
tokenExpiresAt,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useCurrentUser() {
return useContext(AuthContext);
}

View File

@ -0,0 +1,15 @@
type BadgeProps = {
value: number | string;
color?: string;
className?: string;
};
export default function Badge({ value, color = 'bg-red-500', className = '' }: BadgeProps) {
return (
<span
className={`inline-flex items-center py-0.5 px-1.5 rounded-full text-xs font-medium text-white ${color} ${className}`}
>
{value}
</span>
);
}

View File

@ -0,0 +1,97 @@
'use client';
import React from 'react';
type Variant = 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link';
type Size = 'small' | 'default' | 'large';
type Color = 'gray' | 'neutral' | 'teal' | 'blue' | 'red' | 'yellow' | 'white';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: Variant;
size?: Size;
color?: Color;
}
export const Button = ({
children,
variant = 'solid',
size = 'default',
color = 'blue',
className = '',
...props
}: ButtonProps) => {
const base = 'inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border disabled:opacity-50 disabled:pointer-events-none transition cursor-pointer';
const sizeClasses: Record<Size, string> = {
small: 'py-2 px-3',
default: 'py-3 px-4',
large: 'p-4 sm:p-5',
};
const variantColorClasses: Record<Variant, Record<Color, string>> = {
solid: {
blue: 'border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700',
red: 'border-transparent bg-red-600 text-white hover:bg-red-700 focus:bg-red-700',
teal: 'border-transparent bg-teal-600 text-white hover:bg-teal-700 focus:bg-teal-700',
yellow: 'border-transparent bg-yellow-500 text-black hover:bg-yellow-600 focus:bg-yellow-600',
gray: 'border-transparent bg-gray-600 text-white hover:bg-gray-700 focus:bg-gray-700',
neutral: 'border-transparent bg-neutral-600 text-white hover:bg-neutral-700 focus:bg-neutral-700',
white: 'border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:bg-gray-50 dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700',
},
outline: {
blue: 'border border-gray-200 text-gray-500 hover:border-blue-600 hover:text-blue-600 focus:border-blue-600 focus:text-blue-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-blue-500 dark:hover:border-blue-600 dark:focus:text-blue-500 dark:focus:border-blue-600',
red: 'border border-gray-200 text-gray-500 hover:border-red-600 hover:text-red-600 focus:border-red-600 focus:text-red-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-red-500 dark:hover:border-red-600 dark:focus:text-red-500 dark:focus:border-red-600',
teal: 'border border-gray-200 text-gray-500 hover:border-teal-600 hover:text-teal-600 focus:border-teal-600 focus:text-teal-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-teal-500 dark:hover:border-teal-600 dark:focus:text-teal-500 dark:focus:border-teal-600',
yellow: 'border border-gray-200 text-gray-500 hover:border-yellow-500 hover:text-yellow-600 focus:border-yellow-500 focus:text-yellow-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-yellow-400 dark:hover:border-yellow-500 dark:focus:text-yellow-400 dark:focus:border-yellow-500',
gray: 'border border-gray-200 text-gray-500 hover:border-gray-600 hover:text-gray-600 focus:border-gray-600 focus:text-gray-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-gray-500 dark:hover:border-gray-600 dark:focus:text-gray-500 dark:focus:border-gray-600',
neutral: 'border border-gray-200 text-gray-500 hover:border-neutral-600 hover:text-neutral-600 focus:border-neutral-600 focus:text-neutral-600 dark:border-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-500 dark:hover:border-neutral-600 dark:focus:text-neutral-500 dark:focus:border-neutral-600',
white: '', // outline white nicht sinnvoll
},
ghost: {
blue: 'border-transparent text-blue-600 hover:bg-blue-100 hover:text-blue-800 focus:bg-blue-100 focus:text-blue-800 dark:text-blue-500 dark:hover:bg-blue-800/30 dark:hover:text-blue-400 dark:focus:bg-blue-800/30 dark:focus:text-blue-400',
red: 'border-transparent text-red-600 hover:bg-red-100 hover:text-red-800 focus:bg-red-100 focus:text-red-800 dark:text-red-500 dark:hover:bg-red-800/30 dark:hover:text-red-400 dark:focus:bg-red-800/30 dark:focus:text-red-400',
teal: 'border-transparent text-teal-600 hover:bg-teal-100 hover:text-teal-800 focus:bg-teal-100 focus:text-teal-800 dark:text-teal-500 dark:hover:bg-teal-800/30 dark:hover:text-teal-400 dark:focus:bg-teal-800/30 dark:focus:text-teal-400',
yellow: 'border-transparent text-yellow-600 hover:bg-yellow-100 hover:text-yellow-800 focus:bg-yellow-100 focus:text-yellow-800 dark:text-yellow-500 dark:hover:bg-yellow-800/30 dark:hover:text-yellow-400 dark:focus:bg-yellow-800/30 dark:focus:text-yellow-400',
gray: 'border-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800/30 dark:hover:text-gray-400 dark:focus:bg-gray-800/30 dark:focus:text-gray-400',
neutral: 'border-transparent text-neutral-600 hover:bg-neutral-100 hover:text-neutral-800 focus:bg-neutral-100 focus:text-neutral-800 dark:text-neutral-500 dark:hover:bg-neutral-800/30 dark:hover:text-neutral-400 dark:focus:bg-neutral-800/30 dark:focus:text-neutral-400',
white: '',
},
soft: {
blue: 'border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:hover:bg-blue-900 dark:focus:bg-blue-900',
red: 'border-transparent bg-red-100 text-red-800 hover:bg-red-200 focus:bg-red-200 dark:text-red-400 dark:hover:bg-red-900 dark:focus:bg-red-900',
teal: 'border-transparent bg-teal-100 text-teal-800 hover:bg-teal-200 focus:bg-teal-200 dark:text-teal-400 dark:hover:bg-teal-900 dark:focus:bg-teal-900',
yellow: 'border-transparent bg-yellow-100 text-yellow-800 hover:bg-yellow-200 focus:bg-yellow-200 dark:text-yellow-400 dark:hover:bg-yellow-900 dark:focus:bg-yellow-900',
gray: 'border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-900 dark:focus:bg-gray-900',
neutral: 'border-transparent bg-neutral-100 text-neutral-800 hover:bg-neutral-200 focus:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:focus:bg-neutral-900',
white: '',
},
white: {
blue: '', red: '', teal: '', yellow: '', gray: '', neutral: '', white: '', // handled above
},
link: {
blue: 'border-transparent text-blue-600 hover:text-blue-800 focus:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400 dark:focus:text-blue-400',
red: 'border-transparent text-red-600 hover:text-red-800 focus:text-red-800 dark:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400',
teal: 'border-transparent text-teal-600 hover:text-teal-800 focus:text-teal-800 dark:text-teal-500 dark:hover:text-teal-400 dark:focus:text-teal-400',
yellow: 'border-transparent text-yellow-600 hover:text-yellow-800 focus:text-yellow-800 dark:text-yellow-500 dark:hover:text-yellow-400 dark:focus:text-yellow-400',
gray: 'border-transparent text-gray-600 hover:text-gray-800 focus:text-gray-800 dark:text-gray-500 dark:hover:text-gray-400 dark:focus:text-gray-400',
neutral: 'border-transparent text-neutral-600 hover:text-neutral-800 focus:text-neutral-800 dark:text-neutral-500 dark:hover:text-neutral-400 dark:focus:text-neutral-400',
white: '',
},
};
const classes = [
base,
sizeClasses[size],
variantColorClasses[variant][color] || '',
className,
]
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} {...props}>
{children}
</button>
);
};

View File

@ -0,0 +1,169 @@
// CameraList.tsx
import React, { useRef } from 'react';
import { CameraAccessEntry } from '@/types/user';
import DatePicker from './DatePicker';
import { Button } from './Button';
import LoadingSpinner from './LoadingSpinner';
interface CameraListProps {
idPrefix?: string;
cameraOptions: string[];
selectedCameras: CameraAccessEntry[];
cameraDateRanges: Record<string, { startDate: string; endDate: string }>;
isChecked: (camera: string) => boolean;
handleToggle: (camera: string) => void;
handleDateChange: (camera: string, field: 'from' | 'to', value: Date | null) => void;
isLoading?: boolean;
}
/* ───── Helper ───── */
function buildDateValue(entry?: CameraAccessEntry): string {
if (!entry?.from || !entry?.to) return '';
if (isNaN(entry.from.getTime()) || isNaN(entry.to.getTime())) return '';
return `${entry.from.toISOString()}/${entry.to.toISOString()}`;
}
const CameraList: React.FC<CameraListProps> = ({
idPrefix = 'camera',
cameraOptions,
selectedCameras,
cameraDateRanges = {},
isChecked,
handleToggle,
handleDateChange,
isLoading
}) => {
/* Reset-Funktionen der DatePicker pro Kamera merken */
const resetFns = useRef<Record<string, () => void>>({});
/* Datepicker-Change → in Eltern-State schreiben */
const handleDateRangeChange = (
camera: string,
range: { from: Date | null; to: Date | null }
) => {
if (!range.from) { // nichts gewählt
handleDateChange(camera, 'from', null);
handleDateChange(camera, 'to', null);
return;
}
/* always clone damit wir das Original nicht verändern */
const start = new Date(range.from);
start.setHours(0, 0, 0, 0); // 00:00:00.000
const end = range.to
? new Date(range.to)
: new Date(range.from); // falls nur from
end.setHours(23, 59, 59, 999); // 23:59:59.999
handleDateChange(camera, 'from', start);
handleDateChange(camera, 'to', end);
};
/* Checkbox-Klick (inkl. Initial-Daten setzen) */
const handleToggleWithDates = (camera: string) => {
const dateRange = cameraDateRanges?.[camera];
handleToggle(camera); // State gegenprüfen/funktional in UserForm
/* Datumsbereich nur setzen, wenn jetzt angehakt */
if (!isChecked(camera) && dateRange) {
const from = new Date(dateRange.startDate);
const to = new Date(dateRange.endDate);
handleDateChange(camera, 'from', from);
handleDateChange(camera, 'to', to);
}
};
/* RENDER */
return (
<div className="w-full">
{isLoading ? (
<div className="flex flex-col flex-1">
<LoadingSpinner />
</div>
) : (
<ul className="w-full flex flex-col">
{cameraOptions.map((camera, index) => {
const id = `${idPrefix}-checkbox-${index}`;
const entry = selectedCameras.find(e => e.camera === camera);
const checked = isChecked(camera);
const value = buildDateValue(entry);
const { startDate, endDate } = cameraDateRanges?.[camera] || {};
const minDate = startDate ? new Date(startDate) : undefined;
const maxDate = endDate ? new Date(endDate) : undefined;
return (
<li
key={camera}
className="inline-flex items-center gap-x-2 py-0 px-4 bg-white border border-gray-200 text-gray-800 -mt-px first:rounded-t-lg first:mt-0 last:rounded-b-lg dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
>
<div className="grid grid-cols-[auto_2fr_auto] gap-1 items-center w-full">
{/* Checkbox + Label */}
<div className="relative flex items-start w-full p-4">
<input
id={id}
type="checkbox"
checked={checked}
onChange={() => handleToggleWithDates(camera)}
className="border-gray-200 rounded-sm dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800"
/>
<label
htmlFor={id}
className="ms-3.5 block w-full text-sm text-gray-600 dark:text-neutral-500"
>
{camera}
</label>
</div>
{/* DateRange - nur wenn ausgewählt */}
{checked && (
<div className="flex gap-2">
<div className="flex items-center">
<DatePicker
id={id + camera}
title="Unbegrenzt"
selectionDatesMode="multiple-ranged"
onDateChange={range => handleDateRangeChange(camera, range)}
onReset={fn => (resetFns.current[camera] = fn)}
disableUnavailableDates={false}
value={value}
minDate={minDate}
maxDate={maxDate}
suppressInitialChange={true}
/>
</div>
{/* Einzel-Reset-Button */}
<div className="flex items-center">
<Button
type="button"
size="small"
variant="ghost"
color="red"
onClick={e => {
resetFns.current[camera]?.();
handleDateChange(camera, 'from', null);
handleDateChange(camera, 'to', null);
e.currentTarget.blur();
}}
>
Zurücksetzen
</Button>
</div>
</div>
)}
</div>
</li>
);
})
}
</ul>
)}
</div>
)
};
export default CameraList;

View File

@ -0,0 +1,43 @@
// Card.tsx
'use client';
import { ReactNode } from 'react';
type CardProps = {
/** Überschrift oben links (optional) */
title?: string;
/** Optionales Element oben rechts z. B. ein Button */
action?: ReactNode;
/** Inhalt der Karte */
children: ReactNode;
/** Zusätzliche Tailwind-Klassen für das äußere <div> */
className?: string;
};
export default function Card({
title,
action,
children,
className = '',
}: CardProps) {
return (
<div
className={`h-full p-4 flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70
${className}`}
>
{(title || action) && (
<div className="mb-3 flex items-center justify-between gap-4">
{title && (
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{title}
</h2>
)}
{action}
</div>
)}
{children}
</div>
);
}

View File

@ -0,0 +1,88 @@
'use client';
import TimeLine from "./TimeLine";
import TimeLineItem from "./TimeLineItem";
export default function Changelog() {
return (
<TimeLine>
<TimeLineItem title="v2.2" date="08.08.2025">
<ul className="list-disc list-inside">
<li>Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis geliefert hat</li>
<li>Filter für Fahrtrichtung des Fahrzeugs hinzugefügt</li>
<li>Farbe der Treffsicherheit für bessere Lesbarkeit angepasst</li>
<li>Treffsicherheit für Marke & Modell in Details hinzugefügt</li>
<li>Layoutanpassung im Dashboard</li>
</ul>
</TimeLineItem>
<TimeLineItem title="v2.1" date="16.07.2025">
<ul className="list-disc list-inside">
<li>Umbenennung des Projekts in SEKT (<u>SE</u> <u>K</u>ennzeichenerfassungs<u>t</u>ool)</li>
<li>Problem mit der automatischen Abmeldung behoben</li>
<li>Hinweis nach dem Abmelden auf der Loginseite hinzugefügt</li>
<li>Spalte mit dem Zeitpunkt der letzten Anmeldung des Benutzers in der Administration hinzugefügt</li>
</ul>
</TimeLineItem>
<TimeLineItem title="v2.0" date="04.07.2025">
<ul className="list-disc list-inside">
<li>Überarbeitetes Benutzerinterface</li>
<li>Benutzerlogin hinzugefügt</li>
<li>Benutzerrollen hinzugefügt</li>
<li>Neue Funktionen für Admins hinzugefügt:
<ul className="list-disc list-inside pl-6">
<li className="px-6">Neue Benutzer hinzufügen</li>
<li className="px-6">Neues Passwort generieren</li>
<li className="px-6">Zugang sperren</li>
<li className="px-6">Zugang einschränken</li>
<li className="px-6">Benutzer bearbeiten</li>
<li className="px-6">Benutzer löschen</li>
</ul>
</li>
<li>Beschränkung der Ergebnisse auf einen festgelegten Zeitraum</li>
<li>Beschränkung der Ergebnisse für eine festgelegte Kamera</li>
<li>Benachrichtigungen per E-Mail mit benutzerdefinierten Regeln</li>
<li>Automatische Abmeldung nach 5 Minuten Inaktivität</li>
</ul>
</TimeLineItem>
<TimeLineItem title="v1.1" date="18.06.2025">
<ul className="list-disc list-inside">
<li>Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis geliefert hat</li>
</ul>
</TimeLineItem>
<TimeLineItem title="v1.0" date="17.06.2025">
<ul className="list-disc list-inside">
<li>Erster Release</li>
</ul>
</TimeLineItem>
</TimeLine>
/*
<>
<div className="mb-4">
<span className="font-medium text-sm text-gray-500 font-mono mb-3 dark:text-neutral-400">v1.0</span>
<ul className="list-disc list-inside text-gray-800 dark:text-white">
<li>Erster Release</li>
</ul>
</div>
<div className="mb-4">
<span className="font-medium text-sm text-gray-500 font-mono mb-3 dark:text-neutral-400">v1.1</span>
<ul className="list-disc list-inside text-gray-800 dark:text-white">
<li>Problem behoben, bei der Suchergebnisse mit einem Leerzeichen im Suchbegriff keine Ergebnisse geliefert haben</li>
</ul>
</div>
<div className="mb-4">
<span className="font-medium text-sm text-gray-500 font-mono mb-3 dark:text-neutral-400">v2.0</span>
<ul className="list-disc list-inside text-gray-800 dark:text-white">
<li>Benutzerlogin hinzugefügt</li>
<li>Administration für Admins hinzugefügt:
<ul className="list-disc list-inside text-gray-800 dark:text-white">
<li>Beschränkung der Ergebnisse für einen festgelegten Zeitraum hinzugefügt</li>
</ul>
</li>
<li>Beschränkung der Ergebnisse für einen festgelegten Zeitraum hinzugefügt</li>
<li>Benachrichtigungen per E-Mail mit eigenen Regeln für Benutzer hinzugefügt</li>
</ul>
</div>
</>
*/
);
}

View File

@ -0,0 +1,66 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import ClipboardJS from 'clipboard';
type ClipboardProps = {
text: string;
};
export default function Clipboard({ text }: ClipboardProps) {
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!buttonRef.current) return;
const clipboard = new ClipboardJS(buttonRef.current, {
target: () => inputRef.current!,
});
clipboard.on('success', () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
return () => clipboard.destroy();
}, []);
return (
<div className="relative inline-block">
{/* Versteckt für clipboard.js */}
<input
type="text"
ref={inputRef}
value={text}
readOnly
className="sr-only"
id="clipboard-target"
/>
<button
ref={buttonRef}
type="button"
className="px-3 py-2 mb-4 inline-flex items-center break-all justify-between gap-2 text-lg font-mono rounded bg-gray-100 dark:bg-neutral-700 text-gray-800 dark:text-white shadow-2xs hover:bg-gray-50 dark:hover:bg-neutral-600 cursor-pointer max-w-full"
data-clipboard-target="#clipboard-target"
data-clipboard-action="copy"
>
<span className="whitespace-nowrap overflow-auto">{text}</span>
<span className="border-s border-gray-200 ps-2 dark:border-neutral-600 flex items-center">
{!copied ? (
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>
) : (
<svg className="size-4 text-green-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
</button>
</div>
);
}

View File

@ -0,0 +1,278 @@
"use client";
import {
useState,
useEffect,
useRef,
useLayoutEffect,
KeyboardEvent,
useCallback,
HTMLAttributes,
} from "react";
import { createPortal } from "react-dom";
interface ComboBoxProps
extends Omit<HTMLAttributes<HTMLInputElement>, "onChange"> {
/** zwingend, damit aria-Attribute eindeutig sind */
id: string;
/** die wählbaren Werte */
items: string[];
/** controlled value (optional) */
value?: string;
/** uncontrolled default (optional) */
defaultValue?: string;
/** Callback bei Auswahl / freiem Tippen */
onChange?: (value: string) => void;
placeholder?: string;
}
export default function ComboBox({
id,
items,
value,
defaultValue,
onChange,
placeholder = "Auswählen …",
className = "",
...rest
}: ComboBoxProps) {
/* ------------------------------------------------------------------ */
/* State & Refs */
/* ------------------------------------------------------------------ */
const [inputValue, setInputValue] = useState(defaultValue ?? "");
const [isOpen, setIsOpen] = useState(false);
const [highlightIdx, setHighlightIdx] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
/** Koordinaten & Drop-Richtung für das Portal */
const [coords, setCoords] = useState<{
top: number;
left: number;
width: number;
}>({ top: 0, left: 0, width: 0 });
/* ------------------------------------------------------------------ */
/* Controlled -> interne Anzeige synchronisieren */
/* ------------------------------------------------------------------ */
useEffect(() => {
if (typeof value === "string") setInputValue(value);
}, [value]);
/* ------------------------------------------------------------------ */
/* Gefilterte Liste */
/* ------------------------------------------------------------------ */
const filtered = items.filter((it) =>
it.toLowerCase().includes(inputValue.toLowerCase())
);
/* ------------------------------------------------------------------ */
/* Hilfen */
/* ------------------------------------------------------------------ */
const close = () => {
setIsOpen(false);
setHighlightIdx(null);
};
const select = useCallback(
(val: string) => {
if (!value) setInputValue(val);
onChange?.(val);
close();
inputRef.current?.focus();
},
[value, onChange]
);
/* ------------------------------------------------------------------ */
/* Klick außerhalb schließt */
/* ------------------------------------------------------------------ */
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
const insideInput = wrapperRef.current?.contains(target);
const insideList = listRef.current?.contains(target);
if (!insideInput && !insideList) close();
}
// erst nach dem eigentlichen Click des Items auswerten
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}, []);
/* ------------------------------------------------------------------ */
/* Tastatur-Handling */
/* ------------------------------------------------------------------ */
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) return;
if (e.key === "Escape") return close();
if (e.key === "ArrowDown") {
setIsOpen(true);
setHighlightIdx((i) =>
i === null ? 0 : Math.min(i + 1, filtered.length - 1)
);
}
if (e.key === "ArrowUp") {
setIsOpen(true);
setHighlightIdx((i) =>
i === null ? filtered.length - 1 : Math.max(i - 1, 0)
);
}
if (e.key === "Enter" && highlightIdx !== null) {
select(filtered[highlightIdx]);
e.preventDefault();
}
};
/* ------------------------------------------------------------------ */
/* Scroll aktives Item in Sicht */
/* ------------------------------------------------------------------ */
useEffect(() => {
if (highlightIdx === null) return;
const el = listRef.current?.children[
highlightIdx
] as HTMLDivElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [highlightIdx]);
/* ------------------------------------------------------------------ */
/* Position des Portal-Dropdowns berechnen */
/* ------------------------------------------------------------------ */
const placeDropdown = () => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const dropdownEl = listRef.current;
const dropdownH =
dropdownEl?.offsetHeight || parseInt(getComputedStyle(wrapper).lineHeight) * 10 || 260;
// nach oben klappen, wenn unten nicht genug Platz
const enoughBelow = rect.bottom + dropdownH + 4 <= window.innerHeight;
const top = enoughBelow ? rect.bottom + 4 : rect.top - dropdownH - 4;
setCoords({ top, left: rect.left, width: rect.width });
};
/* neu berechnen, sobald Liste geöffnet / Fenster skaliert / gescrollt */
useLayoutEffect(() => {
if (!isOpen) return;
placeDropdown();
window.addEventListener("resize", placeDropdown);
window.addEventListener("scroll", placeDropdown, true); // capture = überall
return () => {
window.removeEventListener("resize", placeDropdown);
window.removeEventListener("scroll", placeDropdown, true);
};
}, [isOpen, filtered.length]);
/* ------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------ */
return (
<>
{/* Eingabefeld --------------------------------------------------- */}
<div
ref={wrapperRef}
className={`relative w-full ${className}`}
aria-expanded={isOpen}
>
<div className="relative">
<input
{...rest}
id={id}
ref={inputRef}
role="combobox"
aria-autocomplete="list"
aria-controls={`${id}-listbox`}
aria-expanded={isOpen}
placeholder={placeholder}
className="cursor-text py-2 sm:py-2.5 ps-4 pe-9 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"
value={inputValue}
onChange={(e) => {
if (!value) setInputValue(e.target.value);
setIsOpen(true);
onChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}
/>
{/* Pfeil-Symbol */}
<button
type="button"
className="cursor-pointer absolute top-1/2 end-3 -translate-y-1/2"
aria-label="Liste öffnen"
onClick={() => setIsOpen((o) => !o)}
>
<svg
className="size-3.5 text-gray-500 dark:text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</button>
</div>
</div>
{/* Dropdown im Portal ------------------------------------------- */}
{isOpen &&
createPortal(
<div
id={`${id}-listbox`}
role="listbox"
ref={listRef}
style={{
position: "absolute",
top: coords.top,
left: coords.left,
width: coords.width,
zIndex: 60, // Modal liegt oft bei 40-50
}}
className="max-h-72 p-1 bg-white border border-gray-200 rounded-lg overflow-y-auto shadow-lg
[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300
dark:bg-neutral-900 dark:border-neutral-700 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500"
>
{filtered.length ? (
filtered.map((it, idx) => (
<div
key={it}
role="option"
aria-selected={highlightIdx === idx}
tabIndex={-1}
onMouseEnter={() => setHighlightIdx(idx)}
onClick={() => select(it)}
className={`cursor-pointer py-2 px-4 text-sm rounded-lg
${
highlightIdx === idx
? "bg-gray-100 dark:bg-neutral-800"
: "text-gray-800 dark:text-neutral-200"
}`}
>
{it}
</div>
))
) : (
<span className="block py-2 px-4 text-sm text-neutral-400">
Nichts gefunden
</span>
)}
</div>,
document.body
)}
</>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { useSSE } from './SSEContext';
export default function ConnectionIndicator() {
const { connectionStatus } = useSSE();
const colorMap = {
connected: 'text-green-500',
connecting: 'text-yellow-500',
error: 'text-red-500',
disconnected: 'text-gray-400',
};
const labelMap = {
connected: 'Verbunden',
connecting: 'Verbindet...',
error: 'Fehler',
disconnected: 'Getrennt',
};
return (
<div className={`w-full sm:w-auto text-center p-2 rounded-xl text-sm bg-gray-50 dark:bg-neutral-800 dark:shadow-neutral-700/70 ${colorMap[connectionStatus]}`}>
{labelMap[connectionStatus]}
</div>
);
}

View File

@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
export default function DarkModeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const saved = localStorage.getItem('hs_theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const activeDark = saved === 'dark' || (!saved && prefersDark);
setIsDark(activeDark);
document.documentElement.classList.toggle('dark', activeDark);
}, []);
const toggleTheme = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add('dark');
localStorage.setItem('hs_theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('hs_theme', 'light');
}
};
return (
<button
onClick={toggleTheme}
className="p-1 inline-flex items-center gap-x-1.5 text-sm text-gray-500 hover:text-gray-800 dark:text-neutral-400 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Theme wechseln"
>
{isDark ? (
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<span className="sr-only">Light Mode aktivieren</span>
</>
) : (
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M12 3a6 6 0 009 9 9 9 0 11-9-9z" />
</svg>
<span className="sr-only">Dark Mode aktivieren</span>
</>
)}
</button>
);
}

View File

@ -0,0 +1,320 @@
// DatePicker.tsx
import { useEffect, useRef, useState } from "react";
import { Calendar, type Options } from "vanilla-calendar-pro";
import "vanilla-calendar-pro/styles/index.css";
type Props = {
id?: string;
title: string;
value?: string;
className?: string;
onDateChange: (range: { from: Date | null; to: Date | null }) => void;
selectionDatesMode?: 'single' | 'multiple' | 'multiple-ranged';
disableUnavailableDates?: boolean;
onReset?: (resetFn: () => void) => void;
displayDateMin?: boolean;
displayDateMax?: boolean;
disablePastDates?: boolean;
camera?: string;
suppressInitialChange?: boolean;
minDate?: Date;
maxDate?: Date;
};
function getAllDatesInRange(start: string, end: string): string[] {
const result = [];
const current = new Date(start);
const to = new Date(end);
while (current <= to) {
result.push(current.toISOString().slice(0, 10));
current.setDate(current.getDate() + 1);
}
return result;
}
/** baut eine *Liste* aller deaktivierten Daten außerhalb [min,max] */
function buildDisabledDates(min: Date, max: Date) {
// wir erzeugen nur ±1 Jahr Puffer, damit das Array schlank bleibt
const earliest = new Date(min);
earliest.setFullYear(earliest.getFullYear() - 1);
const latest = new Date(max);
latest.setFullYear(latest.getFullYear() + 1);
return getAllDatesInRange(
earliest.toISOString().slice(0, 10),
latest.toISOString().slice(0, 10)
).filter(
iso => iso < min.toISOString().slice(0, 10) ||
iso > max.toISOString().slice(0, 10)
);
}
export default function DatePicker({
id,
title,
value,
className = '',
selectionDatesMode = 'multiple-ranged',
disableUnavailableDates = true,
onDateChange,
onReset,
displayDateMin,
displayDateMax,
disablePastDates,
camera,
suppressInitialChange,
minDate,
maxDate,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [calendarInstance, setCalendarInstance] = useState<Calendar | null>(null);
const [initialValueProcessed, setInitialValueProcessed] = useState(false);
const needsFetch = false;
// ---------------------------------------------------------------------------
// 1. Effekt: übergebene "value"-Prop ins Input übernehmen
// ---------------------------------------------------------------------------
useEffect(() => {
if (!inputRef.current) return;
/* Hilfsfunktionen */
const isoDateRE = /^\d{4}-\d{2}-\d{2}$/; // 2025-06-26
const fmt = (d: Date) =>
d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
/* 0) Fall: leer oder undefined → Input leeren */
if (!value) {
inputRef.current.value = '';
if (!suppressInitialChange) onDateChange({ from: null, to: null });
setInitialValueProcessed(true);
return;
}
/* 1) SINGLE-Modus und reines ISO-Datum → einfach übernehmen */
if (selectionDatesMode === 'single' && isoDateRE.test(value)) {
const d = new Date(value);
inputRef.current.value = fmt(d);
if (!suppressInitialChange) onDateChange({ from: d, to: null });
setInitialValueProcessed(true);
return;
}
/* 2) Range-Modus String anhand der drei zulässigen Trenner zerlegen */
const [fromStr, toStr] = value.includes('/')
? value.split('/')
: value.includes(' ')
? value.split(' ')
: value.split(' - '); // genau " - " mit Leerzeichen!
const from = fromStr ? new Date(fromStr.trim()) : null;
const to = toStr ? new Date(toStr.trim()) : null;
if (from && to && !isNaN(from.getTime()) && !isNaN(to.getTime())) {
const sameDay =
from.getFullYear() === to.getFullYear() &&
from.getMonth() === to.getMonth() &&
from.getDate() === to.getDate();
inputRef.current.value = sameDay
? fmt(from)
: `${fmt(from)} ${fmt(to)}`;
if (!suppressInitialChange) onDateChange({ from, to });
} else if (from && !isNaN(from.getTime())) {
// Nur ein Datum im Range-Modus (kommt z. B. vom Ein-Tag-Auto-Close)
inputRef.current.value = fmt(from);
if (!suppressInitialChange) onDateChange({ from, to: from });
} else {
// Fallback: unlesbarer String
inputRef.current.value = '';
if (!suppressInitialChange) onDateChange({ from: null, to: null });
}
setInitialValueProcessed(true);
}, [value, selectionDatesMode, onDateChange, suppressInitialChange]);
useEffect(() => {
if (!inputRef.current || !initialValueProcessed) return;
if (!minDate || !maxDate) return; // warten!
if (calendarInstance) return;
const setupCalendar = (options: Options) => {
const calendar = new Calendar(inputRef.current!, options);
calendar.init();
setCalendarInstance(calendar);
return () => calendar.destroy?.();
};
const commonOptions: Options = {
inputMode: true,
selectionDatesMode,
locale: 'de-DE',
displayDateMin: minDate,
displayDateMax: maxDate,
disableDates:
disableUnavailableDates && minDate && maxDate
? (buildDisabledDates(minDate, maxDate) as unknown as Options['disableDates'])
: undefined,
onChangeToInput(self) {
if (!self.context.inputElement) return;
/* -----------------------------------------------------------
Vanilla-Calendar liefert immer ein Array:
[from] bei single
[from, to] bei range
----------------------------------------------------------- */
const [fromStr, toStr] = self.context.selectedDates;
if (!fromStr) return;
const from = new Date(fromStr);
from.setHours(0, 0, 0, 0); // 00:00 Uhr
/* ---------- SINGLE-Modus ---------------------------------- */
if (selectionDatesMode === 'single') {
self.context.inputElement.value = from.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
onDateChange({ from, to: null }); // ⬅️ to = null
return; // fertig
}
/* ---------- RANGE-Modus ----------------------------------- */
const to = toStr ? new Date(toStr) : new Date(from);
to.setHours(23, 59, 59, 999); // 23:59 Uhr
const fmt = (d: Date) =>
d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const sameDay =
from.getFullYear() === to.getFullYear() &&
from.getMonth() === to.getMonth() &&
from.getDate() === to.getDate();
self.context.inputElement.value = sameDay
? fmt(from)
: `${fmt(from)} - ${fmt(to)}`;
onDateChange({ from, to });
}
};
if (!needsFetch) {
/* Grenzen aus Props respektieren */
if (minDate) commonOptions.displayDateMin = minDate;
if (maxDate) commonOptions.displayDateMax = maxDate;
if (disablePastDates) {
const today = new Date();
commonOptions.displayDateMin = minDate && minDate > today ? minDate : today;
}
if (disableUnavailableDates && minDate && maxDate) {
const all = getAllDatesInRange(
minDate.toISOString().slice(0, 10),
maxDate.toISOString().slice(0, 10)
);
const disabled = all.filter(d =>
new Date(d) < minDate || new Date(d) > maxDate
);
commonOptions.disableDates = disabled; // Array<string>
}
setupCalendar(commonOptions);
return;
}
const query = camera ? `?camera=${encodeURIComponent(camera)}` : '';
fetch(`/api/recognitions/dates${query}`, { credentials: "include" })
.then((res) => res.json())
.then((data) => {
let allDates: Set<string> = new Set();
if (Array.isArray(data)) {
allDates = new Set(data.flatMap(entry => entry.dates));
} else if ('startDate' in data && 'endDate' in data) {
allDates = new Set(getAllDatesInRange(data.startDate, data.endDate));
} else {
console.warn('❌ Unerwartete Datenstruktur:', data);
return;
}
const from = [...allDates].sort()[0];
const to = [...allDates].sort().at(-1)!;
const allRange = getAllDatesInRange(from, to);
const todayStr = new Date().toISOString().slice(0, 10);
const disabledDates = disableUnavailableDates
? allRange.filter(d => !allDates.has(d))
: [];
if (disablePastDates) {
const pastDates = allRange.filter(d => d < todayStr);
pastDates.forEach(d => {
if (!disabledDates.includes(d)) disabledDates.push(d);
});
}
if (minDate) commonOptions.displayDateMin = minDate;
if (maxDate) commonOptions.displayDateMax = maxDate;
if (disablePastDates) {
const today = new Date();
commonOptions.displayDateMin = minDate && minDate > today ? minDate : today;
}
commonOptions.disableDates = disabledDates;
setupCalendar(commonOptions);
});
}, [camera, selectionDatesMode, displayDateMin, displayDateMax, disableUnavailableDates, disablePastDates, minDate, maxDate, initialValueProcessed, calendarInstance, needsFetch, onDateChange]);
useEffect(() => {
if (!calendarInstance || !onReset) return;
const resetFn = () => {
calendarInstance.set({ selectedDates: [] });
if (inputRef.current) inputRef.current.value = '';
onDateChange({ from: null, to: null });
};
onReset(resetFn);
}, [calendarInstance, onReset, onDateChange]);
return (
<div className="flex items-center gap-2">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="w-4 h-4 text-gray-400 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<input
ref={inputRef}
id={id}
readOnly
type="text"
placeholder={title}
className={`cursor-pointer px-3 ps-9 block w-full border-gray-200 shadow-2xs rounded-lg text-lg focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import Changelog from "./Changelog";
import DarkModeToggle from "./DarkModeToggle";
import Modal from "./Modal";
import { useState } from "react";
export default function Footer() {
const [isOpen, setIsOpen] = useState(false);
return (
<footer className="mt-auto static bottom-0 inset-x-0 bg-neutral-100 border-t border-gray-200 dark:bg-neutral-900 dark:border-neutral-700">
<div className="w-full max-w-5xl py-1 mx-auto px-2">
<ul className="flex flex-wrap justify-center items-center whitespace-nowrap sm:gap-2 lg:gap-3">
<li className="inline-flex items-center relative text-xs text-gray-500 pe-3.5 last:pe-0 last:after:hidden after:absolute after:top-1/2 after:end-0 after:inline-block after:size-[3px] after:bg-gray-400 after:rounded-full after:-translate-y-1/2 dark:text-neutral-500 dark:after:bg-neutral-600">
© TEG Düsseldorf - Christoph Rother
</li>
<li className="inline-flex items-center relative text-xs text-gray-500 pe-3.5 last:pe-0 last:after:hidden after:absolute after:top-1/2 after:end-0 after:inline-block after:size-[3px] after:bg-gray-400 after:rounded-full after:-translate-y-1/2 dark:text-neutral-500 dark:after:bg-neutral-600">
<a className="p-1 hover:underline hover:text-gray-800 dark:hover:text-neutral-200" href="mailto:christoph.rother@polizei.nrw.de?subject=Feedback zum Kennzeichenerfassungstool">
Kontakt
</a>
</li>
<li className="inline-flex items-center relative text-xs text-gray-500 pe-3.5 last:pe-0 last:after:hidden after:absolute after:top-1/2 after:end-0 after:inline-block after:size-[3px] after:bg-gray-400 after:rounded-full after:-translate-y-1/2 dark:text-neutral-500 dark:after:bg-neutral-600">
<a className="p-1 hover:underline hover:text-gray-800 dark:hover:text-neutral-200" href="#" onClick={() => setIsOpen(true)}>
v2.2
</a>
</li>
<li className="inline-flex items-center relative text-xs text-gray-500 pe-3.5 last:pe-0 last:after:hidden after:absolute after:top-1/2 after:end-0 after:inline-block after:size-[3px] after:bg-gray-400 after:rounded-full after:-translate-y-1/2 dark:text-neutral-500 dark:after:bg-neutral-600">
<DarkModeToggle />
</li>
</ul>
</div>
<Modal open={isOpen} onClose={() => setIsOpen(false)} title="Changelog">
<Changelog />
</Modal>
</footer>
);
}

View File

@ -0,0 +1,26 @@
import Image from "next/image";
import ConnectionIndicator from './ConnectionIndicator';
import UserGreeting from './UserGreeting';
import Link from "next/link";
// components/Header.tsx
export default function Header() {
return (
<header className="w-full px-4 py-2 border-b border-neutral-300 dark:border-neutral-700">
<div className="w-full flex flex-wrap items-center justify-between gap-4">
{/* Logo + Begrüßung + Logout */}
<div className="flex flex-grow sm:flex-grow-0 items-center gap-4">
<Link href="/">
<Image className='p-2' src="/assets/img/logo.png" alt="TEG Düsseldorf" width={60} height={60} priority />
</Link>
<UserGreeting />
</div>
{/* Verbindungsstatus */}
<div className="flex w-full sm:w-auto justify-center sm:justify-end">
<ConnectionIndicator />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { usePathname } from 'next/navigation';
import Dashboard from '../(protected)/page';
import ResultsPage from '../(protected)/results/page';
import NotiticationsPage from '../(protected)/notifications/page';
import Administration from '../(protected)/admin/page';
import { useCurrentUser } from './AuthContext';
import LoadingSpinner from './LoadingSpinner';
import { useSSE } from './SSEContext';
import { useEffect } from 'react';
export default function HomeClient() {
const { loading } = useCurrentUser();
const pathname = usePathname();
const { resetNewCount } = useSSE();
const tabFromPath = pathname === '/' ? 'dashboard' : pathname.split('/')[1];
useEffect(() => {
if (tabFromPath !== 'results') return;
fetch(`/api/recognitions/reset-count`, {
credentials: 'include',
method: 'POST',
});
resetNewCount();
const timeout = setTimeout(() => {
// Optionales UI-Reset
}, 100);
return () => clearTimeout(timeout);
}, [tabFromPath, resetNewCount]);
if (loading) return <LoadingSpinner showBackground={true} />;
return (
<>
{tabFromPath === 'dashboard' && <Dashboard />}
{tabFromPath === 'results' && <ResultsPage />}
{tabFromPath === 'notifications' && <NotiticationsPage />}
{tabFromPath === 'admin' && <Administration />}
</>
);
}

View File

@ -0,0 +1,116 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from './Button';
import Image from 'next/image';
import Toast from './Toast';
interface ImageZoomModalProps {
src: string;
alt?: string;
onClose: () => void;
}
export default function ImageZoomModal({ src, alt = 'Bild', onClose }: ImageZoomModalProps) {
const [zoom, setZoom] = useState(1);
const [translate, setTranslate] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div
onClick={(e) => {
if (e.target === e.currentTarget) {
setZoom(1);
setTranslate({ x: 0, y: 0 });
onClose();
}
}}
className="fixed inset-0 z-50 bg-black bg-opacity-80 flex items-center justify-center"
>
<div
onClick={(e) => e.stopPropagation()}
className="relative w-full h-full flex items-center justify-center overflow-hidden"
>
{/* Toast (oben links) dauerhaft sichtbar */}
<div className="absolute top-4 left-4 z-50 cursor-default">
<Toast>
<div className="grid grid-cols-[1fr_auto] grid-rows-3 gap-x-4 gap-y-2 text-sm text-black dark:text-white">
<div><kbd className="px-2 py-1 bg-gray-200 text-black rounded">ESC</kbd></div>
<div>Schließen</div>
<div><kbd className="px-2 py-1 bg-gray-200 text-black rounded">Mausrad</kbd></div>
<div>Bild zoomen</div>
<div><kbd className="px-2 py-1 bg-gray-200 text-black rounded">Linke Maustaste</kbd></div>
<div>Bild verschieben</div>
</div>
</Toast>
</div>
{/* Close Button oben rechts */}
<div className="absolute top-4 right-4 z-50">
<Button
onClick={() => {
setZoom(1);
setTranslate({ x: 0, y: 0 });
onClose();
}}
size="default"
variant="solid"
color="red"
>
</Button>
</div>
{/* Bild */}
<div className="relative w-full max-w-4xl aspect-[4/3]">
<Image
src={src}
alt={alt}
fill
unoptimized
className="object-contain select-none cursor-grab active:cursor-grabbing"
style={{
transform: `scale(${zoom}) translate(${translate.x}px, ${translate.y}px)`,
transition: 'transform 0.2s',
}}
onWheel={(e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom((z) => Math.min(Math.max(z + delta, 1), 5));
}}
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startTranslate = { ...translate };
const handleMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
setTranslate({
x: startTranslate.x + dx,
y: startTranslate.y + dy,
});
};
const handleUp = () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
}}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
'use client';
import React from 'react';
type LoadingSpinnerProps = {
showBackground?: boolean;
showBorder?: boolean;
};
export default function LoadingSpinner({ showBackground = false, showBorder = false }: LoadingSpinnerProps) {
const outerClasses = [
'flex flex-col flex-1 justify-center items-center',
showBackground &&
'bg-white shadow-2xs dark:bg-neutral-800 dark:shadow-neutral-700/70',
(showBorder) &&
'border border-gray-200 dark:border-neutral-700',
]
.filter(Boolean)
.join(' ');
return (
<div className={outerClasses}>
<div className="flex justify-center items-center">
<div
className="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-full dark:text-blue-500"
role="status"
aria-label="loading"
>
<span className="sr-only">Lädt...</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
// LoginForm.tsx
'use client';
import Alert from './Alert';
import { Button } from './Button';
type LoginFormProps = {
username: string;
password: string;
error?: string | null;
isLoading?: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
};
export default function LoginForm({
username,
password,
error,
isLoading = false,
onUsernameChange,
onPasswordChange,
onSubmit,
}: LoginFormProps) {
return (
<form onSubmit={onSubmit} className="space-y-4">
{error && <Alert message={error} type='soft' color='danger' />}
<div className='relative'>
<input
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="Benutzername"
className="py-1.5 sm:py-2 px-3 ps-9 block w-full border-gray-200 shadow-2xs rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400"
/>
<div className="absolute inset-y-0 start-0 flex items-center pointer-events-none ps-4 peer-disabled:opacity-50 peer-disabled:pointer-events-none">
<svg className="shrink-0 size-4 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</div>
<div className='relative'>
<input
type="password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
placeholder="Passwort"
className="py-1.5 sm:py-2 px-3 ps-9 block w-full border-gray-200 shadow-2xs rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400"
/>
<div className="absolute inset-y-0 start-0 flex items-center pointer-events-none ps-4 peer-disabled:opacity-50 peer-disabled:pointer-events-none">
<svg className="shrink-0 size-4 text-gray-500 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"></path>
<circle cx="16.5" cy="7.5" r=".5"></circle>
</svg>
</div>
</div>
<Button
type="submit"
color="blue"
size="small"
className="btn btn-primary w-full text-center justify-center"
disabled={isLoading}
>
{isLoading ? (
<>
{/* Spinner */}
<span
className="animate-spin inline-block size-4 border-3 border-current border-t-transparent rounded-full"
role="status"
aria-label="loading"
/>
{/* Beschriftung */}
<span>Lädt...</span>
</>
) : (
'Anmelden'
)}
</Button>
</form>
);
}

View File

@ -0,0 +1,94 @@
'use client';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Button } from './Button';
type ModalProps = {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
saveButton?: boolean;
onSave?: () => void;
maxWidth?: string;
};
export default function Modal({
open,
onClose,
title,
children,
saveButton = false,
onSave,
maxWidth,
}: ModalProps) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 sm:px-0"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
className={`bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full overflow-hidden flex flex-col max-h-[90vh] ${maxWidth ?? 'max-w-lg'}`}
>
{/* Header */}
<div className="flex justify-between items-center px-4 py-3 border-b border-gray-200 dark:border-neutral-700">
<h2 id="modal-title" className="text-lg font-bold text-gray-800 dark:text-white">
{title}
</h2>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-500 dark:text-neutral-400 cursor-pointer"
aria-label="Schließen"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-4 overflow-y-auto text-sm text-gray-700 dark:text-neutral-300">
{children}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-neutral-700">
<Button
onClick={onClose}
color='white'
size='small'
variant='solid'
>
Schließen
</Button>
{saveButton && (
<Button
onClick={onSave}
color='teal'
size='small'
variant='solid'
>
💾 Speichern
</Button>
)}
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,141 @@
// Pagination.tsx
'use client';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
};
export function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
/* ------------------------------------------------------------ */
/* Helper Tokenliste mit immer 7 Einträgen erzeugen */
/* ------------------------------------------------------------ */
type Tok = number | 'dots' | 'hidden';
function buildTokens(): Tok[] {
// Wenige Seiten? → alles zeigen & mit hidden auffüllen
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1) as Tok[];
}
// DefaultTemplate = 1 … A B C … n
const tokens: Tok[] = [1, 'dots', 'hidden', 'hidden', 'hidden', 'dots', totalPages];
// Fall 1 Nahe am Anfang: 1 2 3 4 5 … n
if (currentPage <= 4) {
tokens.splice(1, 5, 2, 3, 4, 5, 'dots');
return tokens;
}
// Fall 2 Nahe am Ende: 1 … n-4 n-3 n-2 n-1 n
if (currentPage >= totalPages - 3) {
tokens.splice(1, 5, 'dots', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1);
return tokens;
}
// Fall 3 Mitte: 1 … p-1 p p+1 … n
tokens.splice(1, 5, 'dots', currentPage - 1, currentPage, currentPage + 1, 'dots');
return tokens;
}
const tokens = buildTokens(); // Länge garantiert 7
/* ------------------------------------------------------------ */
/* Renderer */
/* ------------------------------------------------------------ */
const renderToken = (tok: Tok, idx: number) => {
if (typeof tok === 'number')
return (
<button
key={tok}
onClick={() => onPageChange(tok)}
aria-current={currentPage === tok ? 'page' : undefined}
className={`cursor-pointer min-h-9.5 min-w-9.5 flex justify-center items-center py-2 px-3 text-sm rounded-lg
${
currentPage === tok
? 'bg-gray-200 text-gray-800 dark:bg-neutral-600 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10'
}`}
>
{tok}
</button>
);
if (tok === 'dots')
return (
<span
key={`dots-${idx}`}
className="min-h-9.5 min-w-9.5 flex justify-center items-center text-gray-400 text-sm select-none"
>
</span>
);
// 'hidden' → unsichtbarer Platzhalter (Erhält Breite, nimmt kein gap ein)
return <span key={`hidden-${idx}`} className="hidden sm:inline-block min-h-9.5 min-w-9.5" />;
};
/* ------------------------------------------------------------ */
/* JSX */
/* ------------------------------------------------------------ */
return (
<div className="grid justify-center sm:flex sm:justify-between sm:items-center gap-1 w-full">
<nav className="flex items-center gap-x-1" aria-label="Pagination">
{/* « erste Seite */}
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="First"
className="cursor-pointer min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
>
<ChevronsLeft className="size-3.5 shrink-0" />
</button>
{/* vorherige Seite */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous"
className="cursor-pointer min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
>
<ChevronLeft className="size-3.5 shrink-0" />
</button>
{/* 7 Token (1 … p … n) */}
<div className="flex items-center gap-x-1">{tokens.map(renderToken)}</div>
{/* nächste Seite */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next"
className="cursor-pointer min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
>
<ChevronRight className="size-3.5 shrink-0" />
</button>
{/* letzte Seite » */}
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="Last"
className="cursor-pointer min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
>
<ChevronsRight className="size-3.5 shrink-0" />
</button>
</nav>
</div>
);
}

View File

@ -0,0 +1,45 @@
// /app/components/PrelineScript.tsx
'use client';
import { useEffect } from 'react';
// Optional third-party libraries
import $ from 'jquery';
import _ from 'lodash';
import 'datatables.net';
import 'dropzone/dist/dropzone-min.js';
import * as VanillaCalendarPro from 'vanilla-calendar-pro';
window._ = _;
window.$ = $;
window.jQuery = $;
window.DataTable = $.fn.dataTable;
//window.noUiSlider = noUiSlider;
window.VanillaCalendarPro = VanillaCalendarPro;
// Preline UI
async function loadPreline() {
return import('preline/dist/index.js');
}
export default function PrelineScript() {
useEffect(() => {
const initPreline = async () => {
await loadPreline();
};
initPreline();
}, []);
useEffect(() => {
const init = () => window.HSStaticMethods?.autoInit();
init(); // direkt nach dem Laden
const observer = new MutationObserver(init);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
return null;
}

View File

@ -0,0 +1,12 @@
// /app/components/PrelineScriptWrapper.tsx
'use client';
import dynamic from 'next/dynamic';
const PrelineScript = dynamic(() => import('./PrelineScript'), {
ssr: false,
});
export default function PrelineScriptWrapper() {
return <PrelineScript />;
}

View File

@ -0,0 +1,38 @@
'use client';
type ProgressProps = {
value: number; // Fortschritt in Prozent (0100)
};
export default function Progress({ value }: ProgressProps) {
const safeValue = Math.max(0, Math.min(100, value));
let progressColor = 'bg-red-600 dark:bg-red-500';
if (safeValue > 70) {
progressColor = 'bg-green-600 dark:bg-green-500';
} else if (safeValue > 30) {
progressColor = 'bg-yellow-500 dark:bg-yellow-400';
}
const isZero = safeValue === 0;
const barWidth = isZero ? '100%' : `${safeValue}%`;
const barColor = isZero ? 'bg-gray-300 dark:bg-neutral-600' : progressColor;
return (
<div
className="flex w-full h-4 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-700"
role="progressbar"
aria-valuenow={safeValue}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`flex flex-col justify-center rounded-full overflow-hidden text-xs text-center whitespace-nowrap transition-all duration-500 ${barColor} text-black`}
style={{ width: barWidth }}
>
{safeValue}%
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
// components/RecognitionDetails.tsx
'use client';
import { useState, useEffect } from 'react';
import Progress from './Progress';
import { Recognition } from '../../types/plates';
import Card from './Card';
import LoadingSpinner from './LoadingSpinner';
import Image from 'next/image';
type Props = {
entry: Recognition;
onImageClick: (src: string) => void;
};
export default function RecognitionDetails({ entry, onImageClick }: Props) {
const [imgLoaded, setImgLoaded] = useState(false);
const [plateImgLoaded, setPlateImgLoaded] = useState(false);
// id ändert sich bei jeder Auswahl
useEffect(() => setImgLoaded(false), [entry.id]);
useEffect(() => setPlateImgLoaded(false), [entry.id]);
return (
<Card title={`Details zum Kennzeichen ${entry.license}`}>
<div className="relative inline-block mb-3">
<Image
src={`/images/${entry.imageFile}`}
alt="Foto des Fahrzeugs"
className="w-full h-auto rounded border border-gray-200 dark:border-neutral-700 cursor-zoom-in"
onClick={() => onImageClick(`/images/${entry.imageFile}`)}
onLoad={() => setImgLoaded(true)}
height={400}
width={400}
unoptimized
/>
{/* Overlay nur solange lädt */}
{!imgLoaded && (
<div className="absolute inset-0 bg-white/60 dark:bg-neutral-900/60 flex items-center justify-center">
<LoadingSpinner showBackground={false} />
</div>
)}
</div>
<div className="grid grid-cols-4 gap-x-2 gap-y-1 text-sm text-gray-700 dark:text-neutral-300 items-center">
<div className="col-span-2 font-semibold">Kennzeichen:</div>
<div>{entry.license}</div>
<div className="relative inline-block row-span-2">
<Image
src={`/images/${entry.plateFile}`}
alt="Kennzeichenfoto"
className="w-full h-auto rounded border border-gray-200 dark:border-neutral-700 align-self-center"
onLoad={() => setPlateImgLoaded(true)}
height={200}
width={200}
unoptimized
/>
{/* Overlay nur solange lädt */}
{!plateImgLoaded && (
<div className="absolute inset-0 bg-white/60 dark:bg-neutral-900/60 flex items-center justify-center">
<LoadingSpinner showBackground={false} />
</div>
)}
</div>
<div className="col-span-2 font-semibold">Kennzeichen (formatiert):</div>
<div>{entry.licenseFormatted ?? ''}</div>
<div className="col-span-2 font-semibold">Treffsicherheit (Kennzeichen):</div>
<div className="col-span-2"><Progress value={entry.confidence ?? 0} /></div>
<div className="col-span-2 font-semibold">Land:</div>
<div className="col-span-2">{entry.country ?? ''}</div>
<div className="col-span-2 font-semibold">Marke:</div>
<div className="col-span-2">{entry.brand ?? ''}</div>
<div className="col-span-2 font-semibold">Modell:</div>
<div className="col-span-2">{entry.model ?? ''}</div>
<div className="col-span-2 font-semibold">Treffsicherheit (Marke & Modell):</div>
<div className="col-span-2"><Progress value={entry.brandmodelconfidence ?? 0} /></div>
<div className="col-span-2 font-semibold">Kamera:</div>
<div className="col-span-2">{entry.cameraName ?? ''}</div>
<div className="col-span-2 font-semibold">Richtung:</div>
<div className="col-span-2 flex items-center">
<div>
{typeof entry.directionDegrees === 'number' && (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: `rotate(${entry.directionDegrees}deg)`,
transformOrigin: 'center',
}}
>
<path d="M12 2v20M5 9l7-7 7 7" />
</svg>
)}
</div>
<div className='ml-3 text-sm'>
{entry.direction === 'Away'
? 'abfahrend'
: entry.direction === 'Towards'
? 'ankommend'
: ''}
</div>
</div>
<div className="col-span-2 font-semibold">Zeit (Lokal):</div>
<div className="col-span-2">{new Date(entry.timestampLocal).toLocaleString('de-DE')}</div>
<div className="col-span-2 font-semibold">Zeit (UTC):</div>
<div className="col-span-2">{new Date(entry.timestampUTC).toLocaleString('de-DE')}</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import Progress from './Progress';
import { Recognition } from '../../types/plates';
type Props = {
entry: Recognition;
isSelected: boolean;
isNew: boolean;
onClick: () => void;
};
export default function RecognitionRow({ entry, isSelected, isNew, onClick }: Props) {
const [animatedConfidence, setAnimatedConfidence] = useState(0);
useEffect(() => {
if (isNew) {
setAnimatedConfidence(0);
setTimeout(() => {
setAnimatedConfidence(entry.confidence ?? 0);
}, 50); // Trigger animation
} else {
setAnimatedConfidence(entry.confidence ?? 0);
}
}, [entry.confidence, isNew]);
const baseClass = 'cursor-pointer';
const selectedClass = isSelected && !isNew ? 'bg-gray-200 dark:bg-neutral-700' : '';
const hoverClass = !isSelected ? 'hover:bg-gray-100 dark:hover:bg-neutral-600' : '';
const newClass = isNew ? 'bg-green-50 dark:bg-green-600' : '';
return (
<tr onClick={onClick} className={[baseClass, selectedClass, hoverClass, newClass].filter(Boolean).join(' ')}>
<td className="p-3 font-medium text-gray-800 dark:text-neutral-200">{entry.licenseFormatted}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.country ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.brand ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.model ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">
<Progress value={animatedConfidence} />
</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">
{entry.directionDegrees && entry.direction && (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: `rotate(${entry.directionDegrees}deg)`,
transformOrigin: 'center',
}}
>
<path d="M12 2v20M5 9l7-7 7 7" />
</svg>
)}
</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">
<div className="flex items-center gap-1 text-sm">
{entry.direction === 'Away'
? 'abfahrend'
: entry.direction === 'Towards'
? 'ankommend'
: ''}
</div>
</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">
{new Date(entry.timestampLocal).toLocaleString('de-DE')}
</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.cameraName ?? ''}</td>
</tr>
);
}

View File

@ -0,0 +1,201 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { Recognition } from '../../types/plates';
import { Pagination } from './Pagination';
import ImageZoomModal from './ImageZoomModal';
import RecognitionRow from './RecognitionRow';
import RecognitionDetails from './RecognitionDetails';
import Table from './Table';
import RecognitionsTableFilters from './RecognitionsTableFilters';
import { useSSE } from './SSEContext';
type Props = {
initialSearch: string;
initialPage: number;
resetNewMarkers?: boolean;
};
export default function RecognitionsTable({ resetNewMarkers, initialPage, initialSearch }: Props) {
const [data, setData] = useState<Recognition[]>([]);
const [selected, setSelected] = useState<Recognition | null>(null);
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [currentPage, setCurrentPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(1);
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [newestId, setNewestId] = useState<number | null>(null);
const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({
from: null,
to: null
});
const [directionFilter, setDirectionFilter] = useState<string>('');
const { onNewRecognition } = useSSE();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const itemsPerPage = 10;
// Lese Query-Parameter aus URL
useEffect(() => {
const pageParam = searchParams.get('page');
const searchParam = searchParams.get('search');
const from = searchParams.get('timestampFrom');
const to = searchParams.get('timestampTo');
const directionParam = searchParams.get('direction');
if (pageParam) setCurrentPage(parseInt(pageParam));
if (searchParam) setSearchTerm(searchParam);
if (from || to) {
setDateRange({
from: from ? new Date(from) : null,
to: to ? new Date(to) : null,
});
}
if (directionParam) setDirectionFilter(directionParam);
}, [searchParams]);
// URL bei Interaktion aktualisieren
useEffect(() => {
const params = new URLSearchParams();
if (searchTerm) params.set('search', searchTerm);
if (currentPage > 1) params.set('page', currentPage.toString());
if (dateRange.from) params.set('timestampFrom', dateRange.from.toISOString());
if (dateRange.to) params.set('timestampTo', dateRange.to.toISOString());
if (directionFilter) params.set('direction', directionFilter);
router.replace(`${pathname}?${params.toString()}`);
}, [searchTerm, currentPage, dateRange, directionFilter, pathname, router]);
useEffect(() => {
if (resetNewMarkers) {
setNewestId(null);
}
}, [resetNewMarkers]);
useEffect(() => {
const query = new URLSearchParams({
page: String(currentPage),
limit: String(itemsPerPage),
search: searchTerm,
direction: directionFilter,
});
if (dateRange.from) query.set('timestampFrom', dateRange.from.toISOString());
if (dateRange.to) query.set('timestampTo', dateRange.to.toISOString());
if (directionFilter) query.set('direction', directionFilter);
fetch(`/api/recognitions?${query}`, { credentials: "include", method: "GET" })
.then((res) => res.json())
.then((json) => {
if (Array.isArray(json.data)) {
setData(json.data);
setTotalPages(json.totalPages || 1);
} else {
setData([]);
setTotalPages(1);
}
})
.catch((err) => {
console.error('❌ API-Aufruf fehlgeschlagen:', err);
setData([]);
setTotalPages(1);
});
}, [currentPage, searchTerm, dateRange, directionFilter]);
useEffect(() => {
const cb = (newEntry: Recognition) => {
if (currentPage === 1) {
setData((prev) => {
const alreadyExists = prev.some(entry => entry.id === newEntry.id);
if (alreadyExists) return prev;
return [newEntry, ...prev].slice(0, itemsPerPage);
});
fetch(`/api/recognitions/count`, { credentials: "include" })
.then(res => res.json())
.then(({ count }) => {
const pages = Math.max(1, Math.ceil(count / itemsPerPage));
setTotalPages(pages);
})
.catch(console.error);
setNewestId(newEntry.id);
setTimeout(() => {
setNewestId((prevId) => (prevId === newEntry.id ? null : prevId));
}, 2000);
}
};
onNewRecognition(cb);
}, [currentPage, onNewRecognition]);
const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-4 xl:grid-cols-6">
<div className="col-span-6">
<RecognitionsTableFilters
searchTerm={searchTerm}
directionFilter={directionFilter}
setSearchTerm={setSearchTerm}
setDirectionFilter={setDirectionFilter}
setDateRange={setDateRange}
setCurrentPage={setCurrentPage}
/>
</div>
<div className="col-span-6 md:col-span-3 xl:col-span-4">
<Table>
<Table.Head>
<Table.Row>
<Table.Cell>Kennzeichen</Table.Cell>
<Table.Cell>Land</Table.Cell>
<Table.Cell>Marke</Table.Cell>
<Table.Cell>Modell</Table.Cell>
<Table.Cell>Treffsicherheit</Table.Cell>
<Table.Cell colSpan={2}>Richtung</Table.Cell>
<Table.Cell>Zeit</Table.Cell>
<Table.Cell>Kamera</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{data.map((entry) => (
<RecognitionRow
key={entry.id}
entry={entry}
isSelected={selected?.id === entry.id}
isNew={newestId === entry.id}
onClick={() => setSelected(entry)}
/>
))}
{data.length === 0 && (
<tr>
<td colSpan={7} className="p-5 text-center text-gray-500 dark:text-neutral-400">
Keine Daten gefunden.
</td>
</tr>
)}
</Table.Body>
</Table>
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={goToPage} />
</div>
<div className="col-span-6 md:col-span-3 xl:col-span-2 w-full break-words">
{selected && (
<RecognitionDetails
entry={selected}
onImageClick={(src) => setFullscreenImage(src)}
/>
)}
</div>
{fullscreenImage && (
<ImageZoomModal src={fullscreenImage} onClose={() => setFullscreenImage(null)} />
)}
</div>
);
}

View File

@ -0,0 +1,147 @@
// RecognitionsTableFilters.tsx
'use client';
import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect } from 'react';
import DatePicker from './DatePicker';
import { Button } from './Button';
import Select from './Select';
type Props = {
searchTerm: string;
directionFilter: string;
setSearchTerm: Dispatch<SetStateAction<string>>;
setDirectionFilter: Dispatch<SetStateAction<string>>;
setDateRange: Dispatch<SetStateAction<{ from: Date | null; to: Date | null }>>;
setCurrentPage: Dispatch<SetStateAction<number>>;
selectedCamera?: string | null;
};
export default function RecognitionsTableFilters({
searchTerm,
directionFilter,
setSearchTerm,
setDirectionFilter,
setDateRange,
setCurrentPage,
selectedCamera
}: Props) {
const [hasFilter, setHasFilter] = useState(false);
const resetDatePickerRef = useRef<() => void>(() => {});
const [minDate, setMinDate] = useState<Date | undefined>();
const [maxDate, setMaxDate] = useState<Date | undefined>();
// 📅 Zeitbereich laden
useEffect(() => {
let abort = false;
(async () => {
const query = selectedCamera ? `?camera=${encodeURIComponent(selectedCamera)}` : '';
const res = await fetch(`/api/recognitions/dates${query}`, { credentials: 'include' });
if (!res.ok) return;
const data: { startDate: string; endDate: string }[] = await res.json();
if (abort || data.length === 0) return;
const min = new Date(Math.min(...data.map(d => Date.parse(d.startDate))));
const max = new Date(Math.max(...data.map(d => Date.parse(d.endDate))));
setMinDate(min);
setMaxDate(max);
})();
return () => { abort = true; };
}, [selectedCamera]); // neu laden, wenn der User eine andere Kamera wählt
// 📆 Zeitbereich
const handleDateRangeChange = useCallback((range: { from: Date | null; to: Date | null }) => {
setDateRange(range);
setHasFilter(!!range.from || !!range.to);
setCurrentPage(1);
}, [setDateRange, setCurrentPage]);
// 🔄 Zurücksetzen
const handleReset = () => {
resetDatePickerRef.current?.(); // DatePicker leeren
// Dann die lokalen States aktualisieren
setDateRange({ from: null, to: null });
setHasFilter(false);
setCurrentPage(1);
};
// 📥 Auswahländerung bei Richtung
const handleSelectionChange = (selected: string[]) => {
const value = selected[0] ?? '';
setDirectionFilter(value);
setCurrentPage(1);
};
return (
<div className="flex flex-wrap items-center gap-4 w-full">
{/* 🔍 Suchfeld */}
<div className="relative w-full md:w-auto flex-shrink-0">
<input
id="searchInput"
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="py-2 px-3 ps-9 block w-[220px] border-gray-200 shadow-2xs rounded-lg text-lg focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400"
placeholder="Suchen..."
/>
<div className="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<svg className="size-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</div>
</div>
{/* 📅 DatePicker */}
<div className="flex items-center gap-2 w-full md:w-auto flex-shrink-0">
<DatePicker
id='result-filter'
title='Zeitraum auswählen'
selectionDatesMode='multiple-ranged'
onDateChange={handleDateRangeChange}
onReset={(fn) => { resetDatePickerRef.current = fn; }}
disableUnavailableDates={true}
minDate={minDate}
maxDate={maxDate}
className=""
/>
{hasFilter && (
<Button
onClick={(e) => {
handleReset();
e.currentTarget.blur();
}}
type="button"
variant="ghost"
color="red"
size="small"
>
Zurücksetzen
</Button>
)}
</div>
{/* 🧭 Richtungsauswahl */}
<div className="w-full md:w-auto flex-shrink-0">
<Select
id="direction-filter"
options={[
{ label: 'Alle Richtungen', value: '' },
{ label: 'Ankommend', value: 'towards' },
{ label: 'Abfahrend', value: 'away' },
]}
selected={directionFilter}
multiple={false}
placeholder="Richtung auswählen..."
onChange={handleSelectionChange}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
// app/components/SSEContext.tsx ← vormals SSEContext.tsx
'use client';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { useCurrentUser } from './AuthContext'; // ◄ NEU
import { Recognition } from '@/types/plates';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
type SSEContextType = {
onNewRecognition: (cb: (r: Recognition) => void) => void;
connectionStatus: ConnectionStatus;
newCount: number;
resetNewCount: () => void;
};
const SSEContext = createContext<SSEContextType | undefined>(undefined);
export function SSEProvider({ children }: { children: React.ReactNode }) {
const { user } = useCurrentUser(); // ◄ NEU
const listeners = useRef<((r: Recognition) => void)[]>([]);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [newCount, setNewCount] = useState(0);
/* ───────── EventSource an/abmelden ──────────────────────────────── */
useEffect(() => {
if (!user) { // noch nicht eingeloggt → keine Verbindung
setStatus('disconnected');
return;
}
/* ---------- Verbindung aufbauen -------------------------------- */
setStatus('connecting');
const es = new EventSource(
`/api/recognitions/stream`,
{ withCredentials: true }
);
es.onopen = () => setStatus('connected');
es.addEventListener('new-recognition', e => {
const rec = JSON.parse(e.data) as Recognition;
listeners.current.forEach(cb => cb(rec));
setNewCount(c => c + 1);
});
es.addEventListener('logout', e => {
const { reason } = JSON.parse(e.data);
console.info('Server verlangt Logout:', reason);
fetch(`/api/logout`,
{ method: 'POST', credentials: 'include' })
.finally(() => window.location.href = '/login');
});
es.onerror = err => {
console.warn('SSE-Fehler:', err);
setStatus('error');
};
/* ---------- Aufräumen (Logout / Tab-Wechsel / Hot-Reload) ------ */
return () => {
es.close();
setStatus('disconnected');
};
}, [user, user?.id]); // ◄ Effekt läuft nur, wenn sich der Benutzer ändert
/* ───────── Context-Objekt ───────────────────────────────────────── */
const ctx: SSEContextType = {
onNewRecognition: cb => listeners.current.push(cb),
connectionStatus: status,
newCount,
resetNewCount: () => setNewCount(0),
};
return <SSEContext.Provider value={ctx}>{children}</SSEContext.Provider>;
}
export function useSSE() {
const ctx = useContext(SSEContext);
if (!ctx) throw new Error('useSSE must be used inside SSEProvider');
return ctx;
}

View File

@ -0,0 +1,72 @@
'use client';
import { useRef } from 'react';
type SelectOption = {
label: string;
value: string;
};
type SelectProps = {
id: string;
options: SelectOption[];
multiple?: boolean;
selected?: string;
placeholder?: string;
onChange?: (selected: string[]) => void;
className?: string;
};
export default function Select({
id,
options,
multiple = true,
selected,
placeholder = 'Auswählen...',
onChange,
className = '',
}: SelectProps) {
const selectRef = useRef<HTMLSelectElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValues = Array.from(e.target.selectedOptions).map((opt) => opt.value);
onChange?.(selectedValues);
};
return (
<div className="relative w-full">
{/* 📎 Icon (z.B. Richtung oder generisch) */}
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
className="w-4 h-4 text-gray-400 dark:text-neutral-500"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<select
id={id}
ref={selectRef}
multiple={multiple}
value={selected}
onChange={handleChange}
className={`px-3 ps-9 block w-full border-gray-200 shadow-2xs rounded-lg text-lg focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
>
{!multiple && !selected && (
<option defaultValue="" disabled hidden>
{placeholder}
</option>
)}
{options.map((option, idx) => (
<option key={idx} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,79 @@
import { ReactNode, HTMLAttributes } from 'react';
export function Table({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col mb-1.5">
<div className="-m-1.5 overflow-x-auto">
<div className="p-1.5 min-w-full inline-block align-middle">
<div className="border border-gray-200 rounded-lg overflow-hidden dark:border-neutral-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
{children}
</table>
</div>
</div>
</div>
</div>
);
}
function Head({ children }: { children: ReactNode }) {
return <thead className="bg-gray-50 dark:bg-neutral-700">{children}</thead>;
}
function Body({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-gray-200 dark:divide-neutral-700">{children}</tbody>;
}
function Row({
children,
className = '',
...rest
}: {
children: ReactNode;
} & HTMLAttributes<HTMLTableRowElement>) {
return (
<tr className={className} {...rest}>
{children}
</tr>
);
}
function Cell({
children,
header = false,
align = 'left',
colSpan
}: {
children: ReactNode;
header?: boolean;
align?: 'left' | 'center' | 'right';
colSpan?: number;
}) {
const baseClass = `px-6 py-4 whitespace-nowrap text-sm ${
align === 'right'
? 'text-end'
: align === 'center'
? 'text-center'
: 'text-start'
}`;
const className = header
? `${baseClass} font-medium text-xs text-gray-500 uppercase dark:text-neutral-400`
: `${baseClass} text-gray-800 dark:text-neutral-200`;
return header ? (
<th scope="col" className={className} colSpan={colSpan}>
{children}
</th>
) : (
<td className={className} colSpan={colSpan}>{children}</td>
);
}
// Subkomponenten exportieren
Table.Head = Head;
Table.Body = Body;
Table.Row = Row;
Table.Cell = Cell;
export default Table;

View File

@ -0,0 +1,101 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import React from 'react';
type TabKey = 'dashboard' | 'results' | 'notifications' | 'admin';
type TabsProps = {
newCount?: number;
isAdmin: boolean;
};
export default function Tabs({ newCount = 0, isAdmin = false }: TabsProps) {
const router = useRouter();
const pathname = usePathname();
const activePath = pathname === '' ? '/' : pathname;
const tabs: { key: TabKey; label: string; href: string; icon: React.ReactNode }[] = [
{
key: 'dashboard',
label: 'Dashboard',
href: '/',
icon: (
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
key: 'results',
label: 'Ergebnisse',
href: '/results',
icon: (
<svg className="shrink-0 size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="6" width="14" height="12" rx="2" ry="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="16" cy="8" r="1" />
<path d="M10 6V4h4" />
</svg>
),
},
{
key: 'notifications',
label: 'Benachrichtigungen',
href: '/notifications',
icon: (
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 01-3.46 0" />
</svg>
),
},
...(isAdmin
? [{
key: 'admin' as const,
label: 'Administration',
href: '/admin',
icon: (
<svg className="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
}]
: []),
];
return (
<div className="border-b border-gray-200 dark:border-neutral-700">
<nav className="flex flex-wrap gap-x-1" role="tablist" aria-orientation="horizontal">
{tabs.map((tab) => {
const isActive = activePath === tab.href;
return (
<button
key={tab.key}
onClick={() => router.push(tab.href)}
className={`cursor-pointer flex-1 sm:flex-0 items-center justify-center py-4 px-4 inline-flex items-center gap-x-2 border-b-2 text-sm whitespace-nowrap font-medium transition
${isActive
? 'border-blue-600 text-blue-600 font-semibold dark:text-blue-500'
: 'border-transparent text-gray-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-500'
}`}
role="tab"
aria-selected={isActive}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
{tab.key === 'results' && !isActive && newCount > 0 && (
<span className="inline-flex items-center py-0.5 px-1.5 rounded-full text-xs font-medium bg-red-500 text-white">
{newCount}
</span>
)}
</button>
);
})}
</nav>
</div>
);
}

View File

@ -0,0 +1,69 @@
'use client';
import { useState, useEffect, createContext, useContext } from 'react';
interface ThemeContextType {
theme: string;
setTheme: (theme: string) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export default function ThemeProvider({ children }: { children?: React.ReactNode }) {
const [theme, setTheme] = useState<string>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Theme aus localStorage laden
const savedTheme = localStorage.getItem('hs_theme');
if (savedTheme) {
setTheme(savedTheme);
applyTheme(savedTheme);
} else {
// Standardtheme auf 'light' setzen
setTheme('light');
localStorage.setItem('hs_theme', 'light');
applyTheme('light');
}
}, []);
const applyTheme = (newTheme: string) => {
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
const handleSetTheme = (newTheme: string) => {
setTheme(newTheme);
localStorage.setItem('hs_theme', newTheme);
applyTheme(newTheme);
};
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
handleSetTheme(newTheme);
};
if (!mounted) {
return null;
}
return (
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}

View File

@ -0,0 +1,6 @@
// components/TimeLine.tsx
'use client';
export default function TimeLine({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}

View File

@ -0,0 +1,69 @@
'use client';
import type { ReactNode } from 'react';
import Image from 'next/image';
type Author = {
name: string;
avatar?: string;
initials?: string;
};
export default function TimeLineItem({
title,
date,
icon,
author,
children, // 👈 ReactNode-Children
}: {
title: string;
date: string;
icon?: ReactNode;
author?: Author;
children?: ReactNode;
}) {
return (
<div className="flex gap-x-3">
{/* Vertical line and dot */}
<div className="relative last:after:hidden after:absolute after:top-7 after:bottom-0 after:start-3.5 after:w-px after:-translate-x-[0.5px] after:bg-gray-200 dark:after:bg-neutral-700">
<div className="relative z-10 size-7 flex justify-center items-center">
<div className="size-2 rounded-full bg-gray-400 dark:bg-neutral-600" />
</div>
</div>
{/* Content */}
<div className="grow pt-0.5 pb-8">
<h3 className="flex gap-x-1.5 font-semibold text-gray-800 dark:text-white">
{icon}
{title} - {date}
</h3>
{children && (
<div className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
{children}
</div>
)}
{author && (
<button
type="button"
className="mt-1 -ms-1 p-1 inline-flex items-center gap-x-2 text-xs rounded-lg border border-transparent text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700"
>
{author.avatar ? (
<Image
className="shrink-0 size-4 rounded-full"
src={author.avatar}
alt={author.name}
/>
) : (
<span className="flex shrink-0 justify-center items-center size-4 bg-white border border-gray-200 text-[10px] font-semibold uppercase text-gray-600 rounded-full dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-400">
{author.initials}
</span>
)}
{author.name}
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,187 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import clsx from 'clsx';
import { Button } from './Button';
export default function TimePicker({
id,
value,
onChange,
className = '',
}: {
id: string;
value?: string;
onChange?: (v: string | null) => void;
className?: string;
}) {
/* ------------------- State / Setup ------------------------- */
const dropdownRef = useRef<HTMLDivElement>(null);
const [hour, setHour] = useState<string | null>(null);
const [minute, setMinute] = useState<string | null>(null);
/* Scroll-Container-Refs ------------------------------------- */
const hoursRef = useRef<HTMLDivElement>(null);
const minutesRef = useRef<HTMLDivElement>(null);
const scrollToSelected = (
container: HTMLDivElement | null,
val: string | null
) => {
if (!container || val === null) return;
const label = container.querySelector(
`label input[value="${val}"]`
)?.parentElement as HTMLElement | null;
label?.scrollIntoView({ block: 'nearest' });
};
/* Parent-Wert übernehmen ------------------------------------ */
useEffect(() => {
if (!value) return;
const [h, m] = value.split(':');
setHour(h.padStart(2, '0'));
setMinute(m.padStart(2, '0'));
}, [value]);
/* zusammengesetzte Uhrzeit zurückmelden --------------------- */
useEffect(() => {
if (!onChange) return;
if (hour !== null && minute !== null) onChange(`${hour}:${minute}`);
else onChange(null);
}, [hour, minute, onChange]);
/* ------------------- Dropdown-Optionen --------------------- */
const hours = Array.from({ length: 24 }, (_, i) =>
i.toString().padStart(2, '0')
);
const minutes = ['00', '15', '30', '45'];
const renderOpts = (
opts: string[],
group: string,
selected: string | null,
set: (v: string) => void,
ref: React.RefObject<HTMLDivElement | null>
) => (
<div
ref={ref}
className="p-1 max-h-56 overflow-y-auto
[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-track]:bg-white hover:[&::-webkit-scrollbar-thumb]:bg-gray-300
dark:[&::-webkit-scrollbar-track]:bg-neutral-800 dark:hover:[&::-webkit-scrollbar-thumb]:bg-neutral-500"
>
{opts.map((opt) => (
<label
key={opt}
className={clsx(
`group relative flex justify-center items-center p-1.5 w-10
text-center text-sm cursor-pointer rounded-md
hover:bg-gray-100 dark:hover:bg-neutral-700`,
selected === opt
? 'text-white bg-blue-600 dark:bg-blue-500'
: 'text-gray-800 dark:text-neutral-200'
)}
>
<input
type="radio"
name={group}
value={opt}
checked={selected === opt}
onChange={() => {
set(opt);
// sofort scrollen, wenn man auf ein Item klickt
scrollToSelected(ref.current, opt);
}}
className="hidden"
/>
{opt}
</label>
))}
</div>
);
/* ------------------- Markup ------------------------------- */
return (
<div
ref={dropdownRef}
className="hs-dropdown [--auto-close:inside] relative w-full"
>
{/* Eingabefeld / Toggle ---------------------------------- */}
<div className="relative">
<input
id={id}
type="text"
readOnly
placeholder="hh:mm"
value={hour !== null && minute !== null ? `${hour}:${minute}` : ''}
className={clsx(
`hs-dropdown-toggle cursor-pointer ps-9 pe-3 py-2 block w-full
border-gray-200 rounded-lg shadow-2xs
focus:border-blue-500 focus:ring-blue-500
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-200`,
className
)}
onFocus={() => {
// beim Öffnen initial ans gewählte Element scrollen
scrollToSelected(hoursRef.current, hour);
scrollToSelected(minutesRef.current, minute);
}}
/>
{/* Uhr-Icon ------------------------------------------- */}
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
className="w-4 h-4 text-gray-400 dark:text-neutral-500"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
</div>
{/* Dropdown-Menü --------------------------------------- */}
<div
className="hs-dropdown-menu z-50 mt-2 bg-white border border-gray-200
shadow-xl rounded-lg transition-[opacity,margin] duration
opacity-0 hidden hs-dropdown-open:opacity-100
dark:bg-neutral-800 dark:border-neutral-700"
>
<div className="flex divide-x divide-gray-200 dark:divide-neutral-700">
{renderOpts(hours, `${id}-hours`, hour, setHour, hoursRef)}
{renderOpts(minutes, `${id}-minutes`, minute, setMinute, minutesRef)}
</div>
{/* Footer --------------------------------------------- */}
<div
className="p-1 flex justify-between border-t
border-gray-200 dark:border-neutral-700"
>
<Button
size="small"
variant="ghost"
className="w-full justify-center"
onClick={() => {
const now = new Date();
const h = now.getHours().toString().padStart(2, '0');
const m = Math.floor(now.getMinutes() / 15) * 15;
const mm = m.toString().padStart(2, '0');
setHour(h);
setMinute(mm);
// nach dem Setzen scrollen
scrollToSelected(hoursRef.current, h);
scrollToSelected(minutesRef.current, mm);
}}
>
Jetzt
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
'use client';
import React, { ReactNode } from 'react';
type ToastType = 'info' | 'success' | 'error' | 'warning';
type Props = {
type?: ToastType;
children: ReactNode;
};
const icons: Record<ToastType, ReactNode> = {
info: (
<svg className="size-4 text-blue-500 mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" />
</svg>
),
success: (
<svg className="size-4 text-teal-500 mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
</svg>
),
error: (
<svg className="size-4 text-red-500 mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
),
warning: (
<svg className="size-4 text-yellow-500 mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8 4a.905.905 0 0 0-.9.995l.35 3.507a.552.552 0 0 0 1.1 0l.35-3.507A.905.905 0 0 0 8 4zm.002 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
</svg>
),
};
export default function Toast({ type, children }: Props) {
return (
<div
role="alert"
tabIndex={-1}
className="max-w-xs bg-white border border-gray-200 rounded-xl shadow-lg dark:bg-neutral-800 dark:border-neutral-700"
>
<div className="flex p-4">
{type && <div className="shrink-0">{icons[type]}</div>}
<div className={type ? 'ms-3' : ''}>
<p className="text-sm text-black dark:text-white">{children}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import { ReactNode } from 'react';
import clsx from 'clsx';
type TooltipProps = {
content: string;
position?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
};
export default function Tooltip({ content, position = 'top', children }: TooltipProps) {
const placementClass =
position === 'top'
? ''
: position === 'bottom'
? '[--placement:bottom]'
: position === 'left'
? '[--placement:left]'
: '[--placement:right]';
return (
<div className={clsx('hs-tooltip inline-block', placementClass)}>
<button
type="button"
className="hs-tooltip-toggle size-10 inline-flex justify-center items-center gap-2 rounded-full
bg-gray-50 border border-gray-200 text-gray-600 hover:bg-blue-50 hover:border-blue-200 hover:text-blue-600
focus:outline-hidden focus:bg-blue-50 focus:border-blue-200 focus:text-blue-600
dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-400
dark:hover:bg-white/10 dark:hover:border-white/10 dark:hover:text-white
dark:focus:bg-white/10 dark:focus:border-white/10 dark:focus:text-white"
>
{children}
<span
className="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible
opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2
bg-gray-900 text-xs font-medium text-white rounded-md shadow-2xs dark:bg-neutral-700"
role="tooltip"
>
{content}
</span>
</button>
</div>
);
}

View File

@ -0,0 +1,222 @@
// UserForm.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Button } from './Button';
import Modal from './Modal';
import Clipboard from './Clipboard';
import Alert from './Alert';
import { CameraAccessEntry } from '@/types/user';
import CameraList from './CameraList';
import DatePicker from './DatePicker';
export default function UserForm({ onUserCreated }: { onUserCreated: () => void }) {
const [username, setUsername] = useState('');
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const [selectedCameras, setSelectedCameras] = useState<CameraAccessEntry[]>([]);
const [cameraOptions, setCameraOptions] = useState<string[]>([]);
const [cameraDateRanges, setCameraDateRanges] = useState<Record<string, { startDate: string; endDate: string }>>({});
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [newUser, setNewUser] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const handleExpiresChange = useCallback(({ from }: { from: Date | null }) => {
setExpiresAt(from);
}, []);
/*
Kameralisten & verfügbare Datumsbereiche laden
*/
useEffect(() => {
setIsLoading(true);
fetch(`/api/recognitions/dates`, { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!Array.isArray(data)) return;
setCameraOptions(data.map(d => d.camera));
const ranges: Record<string, { startDate: string; endDate: string }> = {};
data.forEach(d => {
ranges[d.camera] = { startDate: d.startDate, endDate: d.endDate };
});
setCameraDateRanges(ranges);
})
.catch(err => console.error('❌ Fehler beim Laden der Kameras:', err))
.finally(() => setIsLoading(false));
}, []);
/*
Helper: Checkbox-Zustand prüfen
*/
const isChecked = useCallback(
(camera: string) => selectedCameras.some(e => e.camera === camera),
[selectedCameras]
);
/*
Kamera an-/abwählen (funktionale Updates!)
*/
const handleToggle = useCallback((camera: string) => {
setSelectedCameras(prev => {
const exists = prev.find(e => e.camera === camera);
return exists
? prev.filter(e => e.camera !== camera)
: [...prev, { id: -1, camera, from: null, to: null }];
});
}, []);
/*
Datum einer Kamera ändern (funktionale Updates!)
*/
const handleDateChange = useCallback(
(camera: string, field: 'from' | 'to', value: Date | null) => {
setSelectedCameras(prev =>
prev.map(entry =>
entry.camera === camera ? { ...entry, [field]: value } : entry
)
);
},
[]
);
/*
Benutzer anlegen
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const cameraAccess = selectedCameras.map(({ camera, from, to }) => ({
camera,
from: from ? from.toISOString() : null,
to: to ? to.toISOString() : null
}));
const res = await fetch(`/api/admin/create-user`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
expiresAt: expiresAt ? expiresAt.toISOString().split('T')[0] : null,
cameraAccess
})
});
const data = await res.json();
if (res.ok && data.newPassword) {
setNewPassword(data.newPassword);
setNewUser(data.user?.username || null);
setShowPasswordModal(true);
onUserCreated();
}
// Formular zurücksetzen
setUsername('');
setExpiresAt(null);
setSelectedCameras([]);
};
/*
RENDER
*/
return (
<>
<form onSubmit={handleSubmit} className="mb-4 space-y-4 max-w-7xl">
{/* 1⃣ 5-spaltiges Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-[1fr_1fr_2fr_auto] gap-4 items-end">
{/* ── Spalte 1: Benutzername ─────────────────── */}
<div className="xl:max-w-md">
<label
htmlFor="input-username"
className="block text-sm font-medium mb-2 dark:text-white"
>
Benutzername
</label>
<input
id="input-username"
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="block w-full p-3 border-gray-200 rounded-lg text-lg
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400"
required
/>
</div>
{/* ── Spalte 2: Ablaufdatum ─────────────────── */}
<div className="xl:max-w-md">
<label
htmlFor="input-expires"
className="block text-sm font-medium mb-2 dark:text-white"
>
Ablaufdatum
</label>
<DatePicker
id="input-expires"
title="Ablaufdatum"
selectionDatesMode="single"
onDateChange={handleExpiresChange}
minDate={new Date()}
maxDate={new Date(new Date().setFullYear(new Date().getFullYear() + 10))}
disablePastDates
className="py-3"
/>
</div>
{/* ── Spalte 3: Kamera-Liste ─── */}
<div className="min-w-0">
<label className="block text-sm font-medium mb-2 dark:text-white">
Zugriff auf Kameras
</label>
<CameraList
idPrefix="form"
cameraOptions={cameraOptions}
cameraDateRanges={cameraDateRanges}
selectedCameras={selectedCameras}
isChecked={isChecked}
handleToggle={handleToggle}
handleDateChange={handleDateChange}
isLoading={isLoading}
/>
</div>
{/* ── Spalte 4: Button rechts/unten ───────── */}
<div className="justify-self-end self-center w-full">
<label htmlFor="" className="block text-sm font-medium mb-2 dark:text-white">&nbsp;</label>
<Button type="submit" variant="solid" color="blue" className='w-full justify-center'>
Benutzer anlegen
</Button>
</div>
</div>
</form>
{/* Passwort-Modal */}
<Modal
open={showPasswordModal}
onClose={() => {
setShowPasswordModal(false);
setNewPassword(null);
setNewUser(null);
}}
title={`${newUser ?? 'Benutzer'} wurde angelegt`}
>
<p className="mb-2">Das Passwort lautet:</p>
<div className="w-fit mx-auto mb-4">
<Clipboard text={newPassword || ''} />
</div>
<div className="w-fit mx-auto">
<Alert
title="Hinweis"
message="Dieses Passwort wird nur einmal angezeigt. Bitte notieren Sie es jetzt."
type="soft"
color="warning"
/>
</div>
</Modal>
</>
);
}

View File

@ -0,0 +1,177 @@
// UserGreeting.tsx
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { Button } from './Button';
import { useCurrentUser } from './AuthContext';
import { useFade } from '@/app/providers/FadeContext';
import { writeLogoutNotice } from '@/lib/logoutNotice';
function capitalize(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
function getGreeting() {
const hour = new Date().getHours();
if (hour < 11) return 'Morgen';
if (hour < 18) return 'Tag';
return 'Abend';
}
function formatTimeLeft(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const paddedSeconds = s.toString().padStart(2, '0');
return `${m}m ${paddedSeconds}s`;
}
// Texte zentral halten, falls mehrfach benötigt
const LOGOUT_REASON_MANUAL = 'Du hast dich abgemeldet.';
const LOGOUT_REASON_INACTIVE = 'Du wurdest wegen Inaktivität automatisch abgemeldet.';
const LOGOUT_REASON_EXPIRED = 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.';
export default function UserGreeting() {
const { user, logout, tokenExpiresAt } = useCurrentUser();
const INACTIVITY_LIMIT = 5 * 60; // 5 Minuten in Sekunden
const [timeLeft, setTimeLeft] = useState<number>(INACTIVITY_LIMIT);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const refreshedRef = useRef(false);
const cachedUsername =
typeof window !== 'undefined' ? sessionStorage.getItem('username') : null;
const [username, setUsername] = useState(cachedUsername ?? '');
const [isClient, setIsClient] = useState(false);
const fade = useFade();
const doLogoutWithNotice = useCallback(
(noticeReason: 'manual' | 'timeout' | 'expired', message: string) => {
// 1) persistent Notice schreiben
writeLogoutNotice({ reason: noticeReason, message });
// 2) Context-Logout (räumt User + Token)
logout(message);
// 3) Navigation
fade('/login');
},
[logout, fade]
);
const handleLogout = async () => {
doLogoutWithNotice('manual', LOGOUT_REASON_MANUAL);
};
const refreshToken = useCallback(async () => {
try {
const res = await fetch(`/api/refresh-token`, {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
setTimeLeft(INACTIVITY_LIMIT); // Reset auf volle Zeit
sessionStorage.setItem('tokenIssuedAt', `${Date.now()}`);
refreshedRef.current = false;
} else {
console.warn('Token refresh failed');
doLogoutWithNotice('expired', LOGOUT_REASON_EXPIRED);
}
} catch (err) {
console.error('Error refreshing token', err);
// Netzwerkfehler beim Refresh -> wir loggen besser aus, damit User nicht "hängen" bleibt
doLogoutWithNotice('expired', LOGOUT_REASON_EXPIRED);
}
}, [INACTIVITY_LIMIT, doLogoutWithNotice]);
// ⏳ Countdown
useEffect(() => {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
doLogoutWithNotice('timeout', LOGOUT_REASON_INACTIVE);
return 0;
}
// Token bald ablaufend? Refresh
const now = Date.now();
if (
tokenExpiresAt &&
tokenExpiresAt - now <= 30_000 &&
!refreshedRef.current
) {
refreshedRef.current = true;
refreshToken();
}
return prev - 1;
});
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [refreshToken, tokenExpiresAt, doLogoutWithNotice]);
// 🖱 Aktivität zurücksetzen
useEffect(() => {
const reset = () => setTimeLeft(INACTIVITY_LIMIT);
const events = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart'];
events.forEach(e => window.addEventListener(e, reset, { passive: true }));
return () => {
events.forEach(e => window.removeEventListener(e, reset));
};
}, [INACTIVITY_LIMIT]);
useEffect(() => {
if (user?.username) {
setUsername(user.username);
sessionStorage.setItem('username', user.username);
}
}, [user?.username]);
useEffect(() => {
setIsClient(true);
}, []);
const percent = (timeLeft / INACTIVITY_LIMIT) * 100;
if (!isClient) return null;
return (
<div className="flex flex-row sm:items-center gap-2 w-full">
<span className="flex-grow text-lg font-medium text-neutral-700 dark:text-white">
{username ? `Guten ${getGreeting()}, ${capitalize(username)}!` : null}
</span>
<Button
onClick={handleLogout}
size="small"
color="red"
variant="outline"
className="flex-shrink-0 min-w-[120px] sm:min-w-[180px] relative overflow-hidden text-sm font-medium px-3 py-1 rounded bg-red-400"
>
<span className="relative z-10 text-black dark:text-white flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3H6.75A2.25 2.25 0 004.5 5.25v13.5A2.25 2.25 0 006.75 21h6.75a2.25 2.25 0 002.25-2.25V15M18 12H9m0 0l3-3m-3 3l3 3"
/>
</svg>
Abmelden ({formatTimeLeft(timeLeft)})
</span>
<div
className="absolute top-0 left-0 h-full bg-red-600 z-0 transition-all duration-100 ease-linear"
style={{ width: `${percent}%` }}
/>
</Button>
</div>
);
}

View File

@ -0,0 +1,354 @@
// UserTable.tsx
'use client';
import { useEffect, useState, useCallback } from "react";
import { Button } from "./Button";
import Table from "./Table";
import Modal from "./Modal";
import Clipboard from "./Clipboard";
import Alert from "./Alert";
import { CameraAccessEntry, UserWithAccess } from "@/types/user";
import { useCurrentUser } from "./AuthContext";
import DatePicker from "./DatePicker";
import CameraList from "./CameraList";
import LoadingSpinner from "./LoadingSpinner";
export default function UserTable() {
const { user: current } = useCurrentUser();
const [users, setUsers] = useState<UserWithAccess[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserWithAccess | null>(null);
const [editUserModalOpen, setEditUserModalOpen] = useState(false);
const [editUserData, setEditUserData] = useState<UserWithAccess | null>(null);
const [editUserOriginalName, setEditUserOriginalName] = useState<string>('');
const [availableCameras, setAvailableCameras] = useState<string[]>([]);
const [selectedCameras, setSelectedCameras] = useState<CameraAccessEntry[]>([]);
const [cameraDateRanges, setCameraDateRanges] = useState<Record<string, { startDate: string; endDate: string }>>({});
const formatDateTime = (date: string | Date) =>
new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const fetchUsers = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/admin/users`, { credentials: 'include' });
const data = await res.json();
setUsers(data.users);
} catch (err) {
console.error("Fehler beim Laden der Benutzer:", err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleResetPassword = async (user: UserWithAccess) => {
const res = await fetch(`/api/admin/reset-password/${user.id}`, {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (res.ok && data.newPassword) {
setSelectedUser(user);
setNewPassword(data.newPassword);
setShowPasswordModal(true);
}
fetchUsers();
};
const handleBlockAccess = async (userId: number) => {
await fetch(`/api/admin/block-user/${userId}`, {
method: 'POST',
credentials: 'include',
});
fetchUsers();
};
const handleDeleteUser = async (userId: number) => {
if (!confirm('Soll dieser Benutzer wirklich gelöscht werden?')) return;
await fetch(`/api/admin/delete-user/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
fetchUsers();
};
const handleEditUser = async (user: UserWithAccess) => {
setEditUserOriginalName(user.username);
let recognitionRanges: { camera: string; startDate: string; endDate: string }[] = [];
try {
const res = await fetch(`/api/recognitions/dates`, {
credentials: 'include',
});
const raw = await res.json();
recognitionRanges = Array.isArray(raw) ? raw : [];
} catch (err) {
console.warn('⚠️ Fehler beim Laden der Kamera-Zeiträume:', err);
}
const cameraNames = recognitionRanges.map(r => r.camera);
setAvailableCameras(cameraNames);
const rangesMap: Record<string, { startDate: string; endDate: string }> = {};
recognitionRanges.forEach(r => {
rangesMap[r.camera] = { startDate: r.startDate, endDate: r.endDate };
});
setCameraDateRanges(rangesMap);
const accessEntries: CameraAccessEntry[] = user.cameraAccess.map((a: CameraAccessEntry) => ({
id: a.id,
camera: a.camera,
from: a.from ? new Date(a.from) : null,
to: a.to ? new Date(a.to) : null,
}));
setSelectedCameras(accessEntries);
setEditUserData({
...user,
expiresAt: user.expiresAt?.slice(0, 10) ?? ''
});
setEditUserModalOpen(true);
};
const handleEditSubmit = async () => {
if (!editUserData) return;
await fetch(`/api/admin/update-user/${editUserData.id}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: editUserData.username,
expiresAt: editUserData.expiresAt || null,
cameraAccess: selectedCameras.map(({ camera, from, to }) => ({
camera,
from,
to,
}))
}),
});
setEditUserModalOpen(false);
fetchUsers();
};
const isChecked = (camera: string) =>
selectedCameras.some(e => e.camera === camera);
const handleToggle = (camera: string) => {
setSelectedCameras(prev => {
const exists = prev.find(e => e.camera === camera);
return exists
? prev.filter(e => e.camera !== camera)
: [...prev, { id: -1, camera, from: null, to: null }];
});
};
const handleDateChange = (
camera: string,
field: 'from' | 'to',
value: Date | null
) => {
setSelectedCameras(prev =>
prev.map(e =>
e.camera === camera ? { ...e, [field]: value } : e
)
);
};
return (
<>
<Table>
<Table.Head>
<Table.Row>
<Table.Cell>Benutzername</Table.Cell>
<Table.Cell>Ablaufdatum</Table.Cell>
<Table.Cell>Zugriff auf Kameras</Table.Cell>
<Table.Cell>Rolle</Table.Cell>
<Table.Cell>Letzte Anmeldung</Table.Cell>
<Table.Cell>Aktionen</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{isLoading ? (
<Table.Row>
<Table.Cell colSpan={6}>
<div className="flex flex-row justify-center items-center">
<LoadingSpinner />
</div>
</Table.Cell>
</Table.Row>
) : (
users.map(user => (
<Table.Row key={user.id}>
<Table.Cell>
<div className="min-h-[60px] flex items-center">{user.username}</div>
</Table.Cell>
<Table.Cell>
{user.expiresAt
? new Date(user.expiresAt) < new Date()
? `abgelaufen am ${formatDateTime(user.expiresAt)}`
: formatDateTime(user.expiresAt)
: 'unbegrenzt'}
</Table.Cell>
<Table.Cell>
{user.cameraAccess.map(a => {
const from = a.from ? new Date(a.from) : null;
const to = a.to ? new Date(a.to) : null;
const sameDay =
from && to &&
from.getFullYear() === to.getFullYear() &&
from.getMonth() === to.getMonth() &&
from.getDate() === to.getDate();
const dateLabel = !from
? 'unbegrenzt'
: sameDay
? formatDateTime(from)
: `${formatDateTime(from)} ${formatDateTime(to!)}`;
return (
<div key={a.id}>
{a.camera} ({dateLabel})
</div>
);
})}
</Table.Cell>
<Table.Cell>{user.isAdmin ? 'Administrator' : 'Benutzer'}</Table.Cell>
<Table.Cell>
{user.lastLogin ? formatDateTime(user.lastLogin) : 'nie'}
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-2">
{user.username !== current?.username && (
<>
<Button onClick={() => handleResetPassword(user)} color="teal" variant="solid" size="small">🔑 Passwort zurücksetzen</Button>
<Button onClick={() => handleEditUser(user)} color="yellow" variant="solid" size="small"> Bearbeiten</Button>
<Button onClick={() => handleBlockAccess(user.id)} color="red" variant="solid" size="small">🚫 Zugang sperren</Button>
<Button onClick={() => handleDeleteUser(user.id)} color="red" variant="ghost" size="small">🗑 Löschen</Button>
</>
)}
</div>
</Table.Cell>
</Table.Row>
))
)}
</Table.Body>
</Table>
<Modal
open={showPasswordModal}
onClose={() => {
setShowPasswordModal(false);
setNewPassword(null);
}}
title={`Neues Passwort für ${selectedUser?.username ?? 'Benutzer'}`}
>
<p className="mb-2">Das neue Passwort lautet:</p>
<div className="w-fit mx-auto">
<Clipboard text={newPassword || ''} />
</div>
<div className="w-fit mx-auto">
<Alert
title="Warnung"
message="Dieses Passwort wird nur einmal angezeigt und kann danach nicht erneut abgerufen werden."
type="soft"
color="warning"
/>
</div>
</Modal>
<Modal
open={editUserModalOpen}
onClose={() => setEditUserModalOpen(false)}
title={`Benutzer bearbeiten: ${editUserOriginalName}`}
saveButton
onSave={handleEditSubmit}
maxWidth="max-w-2xl"
>
{editUserData && (
<form onSubmit={(e) => { e.preventDefault(); handleEditSubmit(); }} className="space-y-4">
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-sm text-gray-700 dark:text-neutral-300">
<div>
<label htmlFor="edit-username" className="block text-sm font-medium mb-1">Benutzername</label>
<input
id="edit-username"
type="text"
value={editUserData.username}
onChange={(e) =>
setEditUserData((prev) => {
if (!prev) return prev;
return {
...prev,
username: e.target.value,
};
})
}
className="w-full border rounded-lg px-3 py-2.5 dark:bg-neutral-900 dark:border-neutral-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Ablaufdatum</label>
<DatePicker
title="Ablaufdatum"
selectionDatesMode="single"
value={editUserData.expiresAt ?? undefined}
suppressInitialChange
onDateChange={({ from }) =>
setEditUserData((prev) => {
if (!prev) return prev;
return {
...prev,
expiresAt: from
? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
: null,
};
})
}
disablePastDates={true}
minDate={new Date()}
maxDate={new Date(new Date().setFullYear(new Date().getFullYear() + 10))}
className="py-2"
/>
</div>
<div className="col-span-2 w-full">
<label className="block text-sm font-medium mb-2 dark:text-white">Zugriff auf Kameras</label>
<CameraList
idPrefix="edit"
cameraOptions={availableCameras}
cameraDateRanges={cameraDateRanges}
selectedCameras={selectedCameras}
isChecked={isChecked}
handleToggle={handleToggle}
handleDateChange={handleDateChange}
/>
</div>
</div>
</form>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect, useRef, useState } from 'react';
import { Options, Calendar } from 'vanilla-calendar-pro';
import 'vanilla-calendar-pro/styles/index.css';
interface CalendarProps extends React.HTMLAttributes<HTMLDivElement> {
config?: Options,
}
function VanillaCalendar({ config, ...attributes }: CalendarProps) {
const ref = useRef(null);
const [calendar, setCalendar] = useState<Calendar | null>(null);
useEffect(() => {
if (!ref.current) return;
setCalendar(new Calendar(ref.current, config));
}, [ref, config])
useEffect(() => {
if (!calendar) return;
calendar.init()
}, [calendar])
return (
<div {...attributes} ref={ref}></div>
)
}
export default VanillaCalendar;

View File

View File

@ -0,0 +1,119 @@
'use client';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Title,
TooltipItem
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { useMemo } from 'react';
export type DayCount = { date: string; count: number };
export type PlateCount = {
plate: string;
count: number;
brand?: string;
model?: string;
};
type ChartBarProps = {
data: DayCount[];
horizontal?: boolean;
};
ChartJS.register(BarElement, CategoryScale, LinearScale, ChartDataLabels, Tooltip, Legend, Title);
export default function ChartBar({ data, horizontal = false }: ChartBarProps) {
const isDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const chartData = useMemo(() => ({
labels: data.map(d => d.date),
datasets: [
{
label: 'Erkennungen',
data: data.map(d => d.count),
backgroundColor: isDark ? '#3b82f6' : '#2563eb',
borderRadius: 4
}
]
}), [data, isDark]);
const options = useMemo(() => ({
indexAxis: (horizontal ? 'y' : 'x') as 'x' | 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: TooltipItem<'bar'>) => {
const value = context.raw as number;
return `Erkennungen: ${value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value}`;
}
}
},
datalabels: {
color: isDark ? '#f3f4f6' : '#1f2937',
anchor: (horizontal ? 'end' : 'center') as 'center' | 'end',
align: (horizontal ? 'right' : 'top') as 'right' | 'top',
offset: horizontal ? 4 : -6,
font: {
weight: 'bold' as const,
size: 12
},
formatter: (value: number) => (value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value),
}
},
scales: {
x: {
ticks: {
color: isDark ? '#a3a3a3' : '#374151',
font: { size: 13 }
},
grid: {
color: isDark ? '#404040' : '#e5e7eb'
}
},
y: {
ticks: {
color: isDark ? '#a3a3a3' : '#374151',
font: { size: 13 }
},
grid: {
color: isDark ? '#404040' : '#e5e7eb'
}
}
},
animations: {
x: horizontal
? {
type: 'number' as const,
easing: 'easeOutCubic' as const,
duration: 800,
from: 0
}
: undefined,
y: !horizontal
? {
type: 'number' as const,
easing: 'easeOutCubic' as const,
duration: 800,
from: 0
}
: undefined
}
}), [horizontal, isDark]);
return (
<Bar data={chartData} options={options} />
);
}

View File

@ -0,0 +1,27 @@
'use client';
import React from 'react';
import Card from '../Card';
type ChartContainerProps = {
title?: string;
height?: number | string;
className?: string;
children: React.ReactNode;
};
export default function ChartContainer({
title,
height = 'auto',
className = '',
children
}: ChartContainerProps) {
return (
<Card className={`w-full ${className}`}>
{title && <h2 className="text-xl font-semibold mb-2">{title}</h2>}
<div style={{ height }} className="relative w-full">
{children}
</div>
</Card>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { useMemo } from 'react';
import { Pie } from 'react-chartjs-2';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
ChartOptions,
ChartDataset
} from 'chart.js';
import type { DayCount } from './ChartBar';
import ChartDataLabels from 'chartjs-plugin-datalabels';
ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels);
type ChartPieProps = {
data: DayCount[];
legend?: boolean;
};
export default function ChartPie({ data, legend = true }: ChartPieProps) {
const isDark =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const chartData = useMemo(() => {
const labels = data.map(d => d.date);
const series = data.map(d => d.count);
return {
labels,
datasets: [
{
label: 'Erkennungen',
data: series,
backgroundColor: [
'#3b82f6', '#6366f1', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#f97316', '#14b8a6', '#84cc16',
'#22d3ee', '#a78bfa', '#eab308', '#fb7185'
],
borderColor: isDark ? '#1f2937' : '#ffffff',
borderWidth: 1
}
]
};
}, [data, isDark]);
const options: ChartOptions<'pie'> = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: legend,
position: 'bottom',
labels: {
color: isDark ? '#a3a3a3' : '#4b5563'
}
},
tooltip: {
callbacks: {
label: (context) => {
const value = context.raw;
const label = context.label;
return `${label}: ${value} Erkennungen`;
}
}
},
datalabels: {
color: isDark ? '#d1d5db' : '#374151',
font: {
weight: 'bold' as const,
size: 12
},
anchor: 'end',
align: 'start',
offset: 6,
clamp: true,
display: (ctx) => {
const dataset = ctx.chart.data.datasets[0] as ChartDataset<'pie', number[]>;;
const total = dataset.data.reduce((sum: number, val: number) => sum + val, 0);
const value = dataset.data[ctx.dataIndex] as number;
return value / total >= 0.03;
}
},
layout: {
padding: 20
},
clip: false,
},
animation: {
animateRotate: true,
animateScale: true,
duration: 800,
easing: 'easeOutCubic'
}
}), [isDark, legend]);
return (
<Pie data={chartData} options={options} />
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,130 @@
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* Preline UI */
@import "preline/variants.css";
@import "preline/src/plugins/datepicker/styles.css";
/* Plugins */
@plugin "@tailwindcss/forms";
:root {
--background: oklch(97% 0 0);
--foreground: #0a0a0a;
}
.dark {
--background: oklch(20.5% 0 0);
--foreground: #ededed;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");
}
/* light mode (Root) -------------------------------------------- */
:root {
--vc-bg: #ffffff;
--vc-border: #e5e7eb;
--vc-primary: var(--bg-blue-100);
--vc-text: #1f2937;
--vc-text-light: #6b7280;
--vc-text-disabled: #9ca3af;
--vc-arrow-bg: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iIzBmMTcyYSIgZD0iTTEyIDE2Yy0uMyAwLS41LS4xLS43LS4zbC02LTZjLS40LS40LS40LTEgMC0xLjRzMS0uNCAxLjQgMGw1LjMgNS4zIDUuMy01LjNjLjQtLjQgMS0uNCAxLjQgMHMuNCAxIDAgMS40bC02IDZjLS4yLjItLjQuMy0uNy4zIi8+PC9zdmc+");
}
/* dark mode ----------------------------------------------------- */
.dark {
--vc-bg: var(--color-neutral-900);
--vc-border: var(--color-neutral-700);
--vc-primary: var(--bg-blue-100);
--vc-text: #f9fafb;
--vc-text-light: #9ca3af;
--vc-text-disabled: #4b5563;
--vc-arrow-bg: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyIDE2Yy0uMyAwLS41LS4xLS43LS4zbC02LTZjLS40LS40LS40LTEgMC0xLjRzMS0uNCAxLjQgMGw1LjMgNS4zIDUuMy01LjNjLjQtLjQgMS0uNCAxLjQgMHMuNCAxIDAgMS40bC02IDZjLS4yLjItLjQuMy0uNy4zIi8+PC9zdmc+");
}
.vc {
border-color: var(--vc-border) !important;
background-color: var(--vc-bg) !important;
z-index: 9999 !important;
}
.vc-date__btn {
background-color: transparent !important;
}
.vc-arrow:before {
background-image: var(--vc-arrow-bg) !important;
/* color: var(--vc-text-light) !important; */
}
.vc-header__content .vc-month,
.vc-header__content .vc-year {
color: var(--vc-text) !important;
}
.vc-week__day:not([data-vc-week-day-off]),
.vc-date:not([data-vc-date-disabled]) .vc-date__btn {
color: var(--foreground) !important;
}
.vc-date[data-vc-date-today] {
border: 1px dashed var(--color-blue-600) !important;
background-color: transparent !important;
}
.vc-months__month:disabled,
.vc-years__year:disabled {
color: var(--vc-text-disabled) !important;
background-color: var(--vc-border) !important;
}
.vc-date[data-vc-date-selected] .vc-date__btn,
.vc-date[data-vc-date-selected] .vc-date__btn:hover {
color: var(--vc-bg) !important;
background-color: var(--vc-primary) !important;
}
.vc-date[data-vc-date-hover]:not([data-vc-date-today]):not([data-vc-date-hover]) {
background-color: var(--vc-primary) !important;
}
.vc-date[data-vc-date-disabled] .vc-date__btn,
.vc-date[data-vc-date-disabled] .vc-date__btn:hover {
color: var(--vc-text-disabled) !important;
}
.vc-date[data-vc-date-selected] .vc-date__btn,
.vc-date[data-vc-date-selected] .vc-date__btn:hover,
.vc-date:not([data-vc-date-disabled]):not([data-vc-date-selected="first"]):not([data-vc-date-selected="last"]) .vc-date__btn {
color: var(--foreground) !important;
font-weight: 600;
}
.vc-date[data-vc-date-selected],
.vc-date[data-vc-date-selected="first"],
.vc-date[data-vc-date-selected="last"],
.vc-date[data-vc-date-selected="first-and-last"] {
color: var(--vc-text) !important;
}
.vc-date:not([data-vc-date-disabled]):not([data-vc-date-selected="first"]):not([data-vc-date-selected="last"]) .vc-date__btn {
color: var(--vc-text-light) !important;
}
.vc-date {
width: 42px !important;
}

View File

@ -0,0 +1,47 @@
// /app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Script from "next/script";
import PrelineScriptWrapper from "./components/PrelineScriptWrapper";
import Footer from "./components/Footer";
import { AuthProvider } from "./components/AuthContext";
import { SSEProvider } from "./components/SSEContext";
import ThemeProvider from "./components/ThemeProvider";
import { FadeProvider } from "./providers/FadeContext";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Kennzeichenerfassung - TEG Düsseldorf",
description: "",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="de"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable}`}
>
<body className="min-h-screen flex flex-col bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white antialiased">
<ThemeProvider />
<FadeProvider>
<AuthProvider>
<SSEProvider>
<div className="flex-1 flex flex-col">{children}</div>
</SSEProvider>
</AuthProvider>
</FadeProvider>
<Footer />
<Script src="/assets/vendor/lodash/lodash.js" />
<Script src="/assets/vendor/vanilla-calendar-pro/index.js" />
<PrelineScriptWrapper />
</body>
</html>
);
}

View File

@ -0,0 +1,26 @@
// app/providers/FadeContext.tsx
'use client';
import { createContext, useContext, useRef, MutableRefObject } from 'react';
export type FadeFn = (path: string) => void;
/* ① Context enthält nur ein Ref-Objekt */
const FadeCtx = createContext<MutableRefObject<FadeFn> | null>(null);
/* Hook, um die aktuelle Funktion aufzurufen */
export function useFade(): FadeFn {
const ref = useContext(FadeCtx);
return ref ? ref.current : () => {};
}
/* Provider legt das Ref an */
export function FadeProvider({ children }: { children: React.ReactNode }) {
const fnRef = useRef<FadeFn>(() => {}); // initial: noop
return <FadeCtx.Provider value={fnRef}>{children}</FadeCtx.Provider>;
}
/* Hook, den PageTransition benutzt, um seine Funktion zu hinterlegen */
export function useRegisterFade(fn: FadeFn) {
const ref = useContext(FadeCtx);
if (ref) ref.current = fn;
}

View File

@ -0,0 +1,83 @@
'use client';
import { motion, Variants } from 'framer-motion';
import { usePathname, useRouter } from 'next/navigation';
import { createContext, useContext, useRef,
useState, useCallback, useEffect } from 'react';
import { useRegisterFade } from '@/app/providers/FadeContext';
/* ── Variants ─────────────────────── */
const variants: Variants = { enter: {opacity:1}, exit:{opacity:0} };
/* ── Whitelist: wo darf animiert werden? ── */
const ALLOWED: Record<string,string[]> = {
'/login': ['/', '/admin'],
'/': ['/login'],
'/admin': ['/login'],
'/results': ['/login'],
'/notifications': ['/login'],
'/settings': ['/login'],
};
const shouldAnimate = (from:string,to:string)=>
ALLOWED[from]?.includes(to);
/* ── lokaler Context ── */
type FadeNavigate = (p:string)=>void;
const LocalCtx = createContext<FadeNavigate>(()=>{});
export const useFadeNavigate = ()=>useContext(LocalCtx);
/* ── PageTransition ── */
export default function PageTransition({children}:{children:React.ReactNode}) {
const pathname = usePathname();
const router = useRouter();
const [phase, setPhase] = useState<'enter'|'exit'>('enter');
const pending = useRef<string|null>(null);
/* fade-API */
const fadeNavigate: FadeNavigate = useCallback((target)=>{
if (target===pathname) return;
if (!shouldAnimate(pathname,target)) { // kein Fade: sofort
router.replace(target);
return;
}
pending.current = target; // Fade-Modus an
setPhase('exit');
},[pathname,router]);
useRegisterFade(fadeNavigate);
/* Exit fertig → redirect */
const handleComplete = ()=>{
if (phase==='exit' && pending.current){
router.replace(pending.current);
} else if (phase==='enter'){
pending.current=null;
}
};
/* nach Redirect Fade-in */
useEffect(()=>{
if (pending.current) setPhase('enter');
},[pathname]);
/* initialVariant nur bei aktivem Fade nötig */
const initialVariant =
pending.current ? (phase==='exit' ? 'enter' : 'exit') : false;
return (
<LocalCtx.Provider value={fadeNavigate}>
<motion.div
/* wichtiger Unterschied: KEIN key={pathname}! */
variants={variants}
initial={initialVariant}
animate={pending.current ? phase : false}
transition={{duration:0.3}}
onAnimationComplete={handleComplete}
className="flex-1 flex flex-col"
>
{children}
</motion.div>
</LocalCtx.Provider>
);
}

View File

@ -0,0 +1,109 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '../components/Card';
import Alert from '../components/Alert';
type AlertColor =
| 'dark'
| 'secondary'
| 'info'
| 'success'
| 'danger'
| 'warning'
| 'light';
export default function UnsubscribePage() {
const [status, setStatus] = useState<string | null>(null);
const [ruleId, setRuleId] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // <--- NEU
useEffect(() => {
const timer = setTimeout(() => {
const statusMatch = document.cookie.match(/(?:^|; )unsubscribeStatus=([^;]+)/);
const ruleMatch = document.cookie.match(/(?:^|; )unsubscribeRule=([^;]+)/);
const stat = statusMatch ? decodeURIComponent(statusMatch[1]) : null;
const rule = ruleMatch ? decodeURIComponent(ruleMatch[1]) : null;
if (stat) setStatus(stat);
if (rule !== undefined) setRuleId(rule === '' || rule === 'null' || rule === 'all' ? null : rule);
// Flash-Cookies löschen
document.cookie = 'unsubscribeStatus=; Path=/; Max-Age=0; SameSite=Lax;';
document.cookie = 'unsubscribeRule=; Path=/; Max-Age=0; SameSite=Lax;';
setLoading(false); // <--- Ladezustand beenden
}, 100);
return () => clearTimeout(timer);
}, []);
if (loading || status === null) {
return (
<div className="flex justify-center items-center h-full flex-grow">
<Card title="Abmeldung">
<Alert
title="Wird geladen..."
message="Bitte einen Moment Geduld. Deine Abmeldung wird verarbeitet."
type="soft"
color="info"
/>
</Card>
</div>
);
}
// 3. Titel / Nachricht / Typ pro Status + ruleId
let title = '';
let message = '';
let variant: AlertColor = 'info';
const isGlobal = ruleId === null;
switch (status) {
case 'success':
title = 'Austragung erfolgreich';
message = isGlobal
? 'Du wurdest erfolgreich von allen Benachrichtigungen abgemeldet.'
: `Du wurdest erfolgreich von Regel #${ruleId} abgemeldet.`;
variant = 'success';
break;
case 'invalid':
title = 'Ungültiger Link';
message = 'Der Austragungslink ist ungültig oder wurde manipuliert.';
variant = 'warning';
break;
case 'notfound':
title = 'Nicht gefunden';
message = isGlobal
? 'Für deine E-Mail-Adresse waren keine aktiven Einträge mehr vorhanden.'
: `Für Regel #${ruleId} war deine Adresse nicht eingetragen oder du wurdest bereits entfernt.`;
variant = 'warning';
break;
case 'error':
default:
title = 'Fehler';
message = 'Beim Verarbeiten deiner Abmeldung ist ein Fehler aufgetreten.';
variant = 'danger';
break;
}
return (
<div className="flex justify-center items-center h-full flex-grow">
<div className="w-full max-w-md">
<Card title="Abmeldung">
<Alert
title={title}
message={message}
type="solid"
color={variant}
/>
</Card>
</div>
</div>
);
}

35
frontend/src/lib/auth.ts Normal file
View File

@ -0,0 +1,35 @@
import { cookies } from 'next/headers';
import { fetch, Agent } from 'undici'; // ⬅️ Agent von undici!
const BASE_URL = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
const agent = new Agent({
connect: {
rejectUnauthorized: false, // self-signed Zertifikate akzeptieren
},
});
export type User = {
id: string;
username: string;
isAdmin: boolean;
tokenExpiresAt?: number;
};
export async function getServerUser(): Promise<User | null> {
const cookieStore = await cookies(); // synchron
const cookieHeader = cookieStore.toString();
if (!cookieHeader) return null;
const res = await fetch(`${BASE_URL}/api/me`, {
headers: {
cookie: cookieHeader,
},
dispatcher: agent, // ✔️ funktioniert jetzt
});
if (!res.ok) return null;
return res.json() as Promise<User>;
}

View File

@ -0,0 +1,126 @@
// /lib/logoutNotice.ts
/**
* Alle möglichen Logout-Gründe, die das Frontend unterscheiden kann.
*/
export type LogoutReason =
| 'manual' // User klickt "Abmelden"
| 'timeout' // Inaktivitäts-Logout (Client)
| 'expired' // Token/Sitzung abgelaufen (Server / 401)
| 'server' // Server hat Logout erzwungen (Admin-Force-Logout etc.)
| 'error'; // Technischer Fehler / Fallback
/**
* Optionen für logout().
*/
export interface LogoutOptions {
reason?: LogoutReason;
message?: string;
suppressNotice?: boolean; // true = nichts in Storage schreiben
meta?: Record<string, unknown>;
}
/** Struktur im localStorage. */
export interface LogoutNoticePayload {
reason: LogoutReason;
message: string;
ts: number;
meta?: Record<string, unknown>;
}
export const LOGOUT_NOTICE_STORAGE_KEY = 'logoutNotice';
/* ---------- Public API ---------- */
export function writeLogoutNotice(opts: LogoutOptions = {}): void {
if (opts.suppressNotice) return;
const payload: LogoutNoticePayload = {
reason: opts.reason ?? 'manual',
message: opts.message ?? defaultMessage(opts.reason),
ts: Date.now(),
meta: opts.meta,
};
try {
localStorage.setItem(LOGOUT_NOTICE_STORAGE_KEY, JSON.stringify(payload));
} catch {
/* ignore */
}
}
export function readLogoutNotice(clear: boolean = true): LogoutNoticePayload | null {
try {
const raw = localStorage.getItem(LOGOUT_NOTICE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<LogoutNoticePayload>;
if (!parsed || typeof parsed.message !== 'string') return null;
const payload: LogoutNoticePayload = {
reason: isLogoutReason(parsed.reason) ? parsed.reason : 'manual',
message: parsed.message,
ts: typeof parsed.ts === 'number' ? parsed.ts : Date.now(),
meta: parsed.meta,
};
if (clear) clearLogoutNotice();
return payload;
} catch {
return null;
}
}
export function clearLogoutNotice(): void {
try {
localStorage.removeItem(LOGOUT_NOTICE_STORAGE_KEY);
} catch {
/* ignore */
}
}
/* ---------- Mapping für UI-Alerts (optional, aber praktisch) ---------- */
export function mapLogoutReasonToAlert(reason?: LogoutReason) {
switch (reason) {
case 'manual':
return { type: 'soft' as const, color: 'info' as const, title: 'Abgemeldet' };
case 'timeout':
return { type: 'soft' as const, color: 'warning' as const, title: 'Inaktivitäts-Logout' };
case 'expired':
return { type: 'soft' as const, color: 'danger' as const, title: 'Sitzung abgelaufen' };
case 'server':
return { type: 'soft' as const, color: 'danger' as const, title: 'Server-Logout' };
case 'error':
return { type: 'soft' as const, color: 'secondary' as const, title: 'Fehler' };
default:
return { type: 'soft' as const, color: 'info' as const, title: 'Hinweis' };
}
}
/* ---------- intern ---------- */
function defaultMessage(reason: LogoutReason | undefined): string {
switch (reason) {
case 'timeout':
return 'Du wurdest wegen Inaktivität abgemeldet.';
case 'expired':
return 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.';
case 'server':
return 'Du wurdest vom Server abgemeldet.';
case 'error':
return 'Du wurdest abgemeldet (technischer Fehler).';
case 'manual':
default:
return 'Du hast dich abgemeldet.';
}
}
function isLogoutReason(val: unknown): val is LogoutReason {
return (
val === 'manual' ||
val === 'timeout' ||
val === 'expired' ||
val === 'server' ||
val === 'error'
);
}

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react';
export function useSessionRefresher() {
useEffect(() => {
let lastActivity = Date.now();
const markActive = () => { lastActivity = Date.now(); };
const refreshToken = async () => {
const idleFor = Date.now() - lastActivity;
if (idleFor < 4 * 60 * 1000) { // Nur wenn User aktiv in letzter Zeit
await fetch('/api/refresh-token', { method: 'POST', credentials: 'include' });
}
};
window.addEventListener('mousemove', markActive);
window.addEventListener('keydown', markActive);
const interval = setInterval(refreshToken, 60_000);
return () => {
clearInterval(interval);
window.removeEventListener('mousemove', markActive);
window.removeEventListener('keydown', markActive);
};
}, []);
}

View File

@ -0,0 +1,66 @@
import { JwtPayload } from 'jsonwebtoken';
import { NextRequest, NextResponse } from 'next/server';
// Pfade, die öffentlich zugänglich sind
//const PUBLIC_PATHS = ['/login'];
// Statische Assets ignorieren
const PUBLIC_FILE = /\.(.*)$/;
function decodeJwtPayload(token: string): JwtPayload | null {
try {
const base64Payload = token.split('.')[1];
const payload = atob(base64Payload);
return JSON.parse(payload);
} catch {
return null;
}
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const token = req.cookies.get('token')?.value;
const payload = token ? decodeJwtPayload(token) : null;
// 🔓 Ignoriere API, static, _next etc.
if (
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon.ico') ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
// 🔓 Login-Seite: Umleiten, wenn bereits eingeloggt
if (pathname === '/login') {
if (payload) {
const redirectTo = payload.isAdmin ? '/admin' : '/';
return NextResponse.redirect(new URL(redirectTo, req.url));
}
return NextResponse.next();
}
// 🔐 Kein Token → redirect zu Login
if (!token || !payload) {
return NextResponse.redirect(new URL('/login', req.url));
}
// 🔒 Admin-Check
if (pathname.startsWith('/admin') && !payload.isAdmin) {
return NextResponse.redirect(new URL('/', req.url));
}
// ✅ Token vorhanden und gültig genug
return NextResponse.next();
}
export const config = {
matcher: [
'/:path*',
'/results/:path*',
'/notifications/:path*',
'/admin/:path*',
'/login',
],
};

View File

@ -0,0 +1,19 @@
export interface Recognition {
id: number;
license: string;
licenseFormatted?: string | null;
country?: string | null;
confidence: number;
timestampUTC: string;
timestampLocal: string;
cameraName?: string | null;
classification?: string | null;
direction?: string | null;
directionDegrees?: number;
imageFile: string;
plateFile: string;
brand?: string | null;
model?: string | null;
brandmodelconfidence?: number;
createdAt: string;
}

View File

@ -0,0 +1,15 @@
export type CameraAccessEntry = {
id: number;
camera: string;
from: Date | null;
to: Date | null;
};
export type UserWithAccess = {
id: number;
username: string;
isAdmin: boolean;
expiresAt: string | null;
lastLogin?: string | null;
cameraAccess: CameraAccessEntry[];
};

View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
safelist: [
{
pattern: /(bg|text|hover:bg|hover:text|border)-(red|blue|gray|teal|neutral|yellow|white)-(50|100|200|300|400|500|600|700|800|900)/,
},
],
theme: {
extend: {},
},
plugins: [],
};

41
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"frontend/.next/types/**/*.ts",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"frontend/node_modules"
]
}