updated
This commit is contained in:
commit
9a0fff5385
9
backend/.env
Normal file
9
backend/.env
Normal 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
23
backend/certs/myRoot.crt
Normal 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
27
backend/certs/myRoot.key
Normal 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
1767
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/package.json
Normal file
20
backend/package.json
Normal 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
BIN
backend/prisma/dev.db
Normal file
Binary file not shown.
84
backend/prisma/migrations/20250807104541_/migration.sql
Normal file
84
backend/prisma/migrations/20250807104541_/migration.sql
Normal 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");
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recognition" ADD COLUMN "brandmodelconfidence" INTEGER;
|
||||
ALTER TABLE "Recognition" ADD COLUMN "direction" TEXT;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recognition" ADD COLUMN "directionDegrees" INTEGER;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
109
backend/prisma/schema.prisma
Normal file
109
backend/prisma/schema.prisma
Normal 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
35
backend/prisma/seed.js
Normal 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();
|
||||
});
|
||||
180
backend/prisma/update-direction.js
Normal file
180
backend/prisma/update-direction.js
Normal 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
1609
backend/server.js
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal 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
36
frontend/README.md
Normal 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.
|
||||
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal 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
21
frontend/global.d.ts
vendored
Normal 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
46
frontend/next.config.ts
Normal 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
6915
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
frontend/package.json
Normal file
61
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/assets/img/logo.png
Normal file
BIN
frontend/public/assets/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 526 KiB |
17209
frontend/public/assets/vendor/lodash/lodash.js
vendored
Normal file
17209
frontend/public/assets/vendor/lodash/lodash.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/assets/vendor/vanilla-calendar-pro/index.css
vendored
Normal file
1
frontend/public/assets/vendor/vanilla-calendar-pro/index.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/public/assets/vendor/vanilla-calendar-pro/index.js
vendored
Normal file
2
frontend/public/assets/vendor/vanilla-calendar-pro/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
20
frontend/src/app/(auth)/login/layout.tsx
Normal file
20
frontend/src/app/(auth)/login/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
frontend/src/app/(auth)/login/page.tsx
Normal file
146
frontend/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/(protected)/admin/page.tsx
Normal file
13
frontend/src/app/(protected)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/app/(protected)/layout.tsx
Normal file
36
frontend/src/app/(protected)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
604
frontend/src/app/(protected)/notifications/page.tsx
Normal file
604
frontend/src/app/(protected)/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
frontend/src/app/(protected)/page.tsx
Normal file
236
frontend/src/app/(protected)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/(protected)/results/page.tsx
Normal file
46
frontend/src/app/(protected)/results/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
frontend/src/app/components/Alert.tsx
Normal file
62
frontend/src/app/components/Alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
frontend/src/app/components/AuthContext.tsx
Normal file
144
frontend/src/app/components/AuthContext.tsx
Normal 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);
|
||||
}
|
||||
15
frontend/src/app/components/Badge.tsx
Normal file
15
frontend/src/app/components/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/app/components/Button.tsx
Normal file
97
frontend/src/app/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
169
frontend/src/app/components/CameraList.tsx
Normal file
169
frontend/src/app/components/CameraList.tsx
Normal 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;
|
||||
43
frontend/src/app/components/Card.tsx
Normal file
43
frontend/src/app/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/app/components/Changelog.tsx
Normal file
88
frontend/src/app/components/Changelog.tsx
Normal 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>
|
||||
</>
|
||||
*/
|
||||
);
|
||||
}
|
||||
66
frontend/src/app/components/Clipboard.tsx
Normal file
66
frontend/src/app/components/Clipboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
frontend/src/app/components/ComboBox.tsx
Normal file
278
frontend/src/app/components/ComboBox.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/components/ConnectionIndicator.tsx
Normal file
27
frontend/src/app/components/ConnectionIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/app/components/DarkModeToggle.tsx
Normal file
54
frontend/src/app/components/DarkModeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
frontend/src/app/components/DatePicker.tsx
Normal file
320
frontend/src/app/components/DatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
frontend/src/app/components/Footer.tsx
Normal file
38
frontend/src/app/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/components/Header.tsx
Normal file
26
frontend/src/app/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/components/HomeClient.tsx
Normal file
46
frontend/src/app/components/HomeClient.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
frontend/src/app/components/ImageZoomModal.tsx
Normal file
116
frontend/src/app/components/ImageZoomModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/app/components/LoadingSpinner.tsx
Normal file
34
frontend/src/app/components/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/app/components/LoginForm.tsx
Normal file
84
frontend/src/app/components/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/app/components/Modal.tsx
Normal file
94
frontend/src/app/components/Modal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
141
frontend/src/app/components/Pagination.tsx
Normal file
141
frontend/src/app/components/Pagination.tsx
Normal 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[];
|
||||
}
|
||||
|
||||
// Default‐Template = 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/app/components/PrelineScript.tsx
Normal file
45
frontend/src/app/components/PrelineScript.tsx
Normal 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;
|
||||
}
|
||||
12
frontend/src/app/components/PrelineScriptWrapper.tsx
Normal file
12
frontend/src/app/components/PrelineScriptWrapper.tsx
Normal 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 />;
|
||||
}
|
||||
38
frontend/src/app/components/Progress.tsx
Normal file
38
frontend/src/app/components/Progress.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
type ProgressProps = {
|
||||
value: number; // Fortschritt in Prozent (0–100)
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
131
frontend/src/app/components/RecognitionDetails.tsx
Normal file
131
frontend/src/app/components/RecognitionDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/app/components/RecognitionRow.tsx
Normal file
76
frontend/src/app/components/RecognitionRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/app/components/RecognitionsTable.tsx
Normal file
201
frontend/src/app/components/RecognitionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/src/app/components/RecognitionsTableFilters.tsx
Normal file
147
frontend/src/app/components/RecognitionsTableFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/components/SSEContext.tsx
Normal file
82
frontend/src/app/components/SSEContext.tsx
Normal 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;
|
||||
}
|
||||
72
frontend/src/app/components/Select.tsx
Normal file
72
frontend/src/app/components/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/app/components/Table.tsx
Normal file
79
frontend/src/app/components/Table.tsx
Normal 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;
|
||||
101
frontend/src/app/components/Tabs.tsx
Normal file
101
frontend/src/app/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/app/components/ThemeProvider.tsx
Normal file
69
frontend/src/app/components/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/app/components/TimeLine.tsx
Normal file
6
frontend/src/app/components/TimeLine.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
// components/TimeLine.tsx
|
||||
'use client';
|
||||
|
||||
export default function TimeLine({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
69
frontend/src/app/components/TimeLineItem.tsx
Normal file
69
frontend/src/app/components/TimeLineItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/app/components/TimePicker.tsx
Normal file
187
frontend/src/app/components/TimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/app/components/Toast.tsx
Normal file
50
frontend/src/app/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/app/components/Tooltip.tsx
Normal file
45
frontend/src/app/components/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/components/UserForm.tsx
Normal file
222
frontend/src/app/components/UserForm.tsx
Normal 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"> </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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
frontend/src/app/components/UserGreeting.tsx
Normal file
177
frontend/src/app/components/UserGreeting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
frontend/src/app/components/UserTable.tsx
Normal file
354
frontend/src/app/components/UserTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
frontend/src/app/components/VanillaCalendar.tsx
Normal file
29
frontend/src/app/components/VanillaCalendar.tsx
Normal 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;
|
||||
0
frontend/src/app/components/charts/Chart
Normal file
0
frontend/src/app/components/charts/Chart
Normal file
119
frontend/src/app/components/charts/ChartBar.tsx
Normal file
119
frontend/src/app/components/charts/ChartBar.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/components/charts/ChartContainer.tsx
Normal file
27
frontend/src/app/components/charts/ChartContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/app/components/charts/ChartPie.tsx
Normal file
103
frontend/src/app/components/charts/ChartPie.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
130
frontend/src/app/globals.css
Normal file
130
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
47
frontend/src/app/layout.tsx
Normal file
47
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/providers/FadeContext.tsx
Normal file
26
frontend/src/app/providers/FadeContext.tsx
Normal 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;
|
||||
}
|
||||
83
frontend/src/app/providers/PageTransition.tsx
Normal file
83
frontend/src/app/providers/PageTransition.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/app/unsubscribe/page.tsx
Normal file
109
frontend/src/app/unsubscribe/page.tsx
Normal 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
35
frontend/src/lib/auth.ts
Normal 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>;
|
||||
}
|
||||
126
frontend/src/lib/logoutNotice.ts
Normal file
126
frontend/src/lib/logoutNotice.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
27
frontend/src/lib/useSessionRefresher.ts
Normal file
27
frontend/src/lib/useSessionRefresher.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
66
frontend/src/middleware.ts
Normal file
66
frontend/src/middleware.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
19
frontend/src/types/plates.ts
Normal file
19
frontend/src/types/plates.ts
Normal 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;
|
||||
}
|
||||
15
frontend/src/types/user.ts
Normal file
15
frontend/src/types/user.ts
Normal 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[];
|
||||
};
|
||||
16
frontend/tailwind.config.js
Normal file
16
frontend/tailwind.config.js
Normal 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
41
frontend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user