Compare commits
2 Commits
9a0fff5385
...
f87e00ebf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87e00ebf3 | ||
|
|
cb24c62f75 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
backend/node_modules
|
||||
backend/prisma/dev.db
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
@ -6,4 +6,5 @@ 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
|
||||
FRONTEND_ORIGIN=https://sekt.tegdssd.de
|
||||
#FRONTEND_ORIGIN=https://sekt.local
|
||||
1436
backend/package-lock.json
generated
1436
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.11.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@ -11,10 +11,13 @@
|
||||
"fast-xml-parser": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"puppeteer": "^24.29.1",
|
||||
"sharp": "^0.34.2",
|
||||
"undici": "^7.16.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.11.0"
|
||||
"prisma": "^6.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -0,0 +1,10 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserFeature" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"feature" TEXT NOT NULL,
|
||||
CONSTRAINT "UserFeature_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserFeature_userId_feature_key" ON "UserFeature"("userId", "feature");
|
||||
@ -11,6 +11,19 @@ datasource db {
|
||||
* ───────────── Bestehende Tabellen ─────────────
|
||||
*/
|
||||
|
||||
enum Feature {
|
||||
DOWNLOADS
|
||||
}
|
||||
|
||||
model UserFeature {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
feature Feature
|
||||
|
||||
@@unique([userId, feature], name: "userId_feature_unique")
|
||||
}
|
||||
|
||||
model Recognition {
|
||||
id Int @id @default(autoincrement())
|
||||
license String
|
||||
@ -42,6 +55,7 @@ model User {
|
||||
lastLogin DateTime?
|
||||
cameraAccess CameraAccess[]
|
||||
notificationRules NotificationRule[] @relation("UserRules")
|
||||
features UserFeature[]
|
||||
}
|
||||
|
||||
model CameraAccess {
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
1022
backend/server.js
1022
backend/server.js
File diff suppressed because it is too large
Load Diff
61
frontend/data/changelog.xml
Normal file
61
frontend/data/changelog.xml
Normal file
@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<changelog>
|
||||
<entry version="v2.4" date="2025-11-07">
|
||||
<item>Darstellung der Ergebnistabelle wurde verbessert</item>
|
||||
<item>Kleine Anpassungen im hellen und dunklen Theme vorgenommen</item>
|
||||
<item>Aktualisierung des Dashboards</item>
|
||||
<item>Problem behoben, bei dem der Export eines PDFs mit sehr vielen Seiten nicht richtig funktionierte</item>
|
||||
|
||||
</entry>
|
||||
|
||||
<entry version="v2.3" date="2025-11-05">
|
||||
<item>Filter für Kameras hinzugefügt</item>
|
||||
<item>Downloadseite für Firmware und Dokumentationen hinzugefügt</item>
|
||||
<item>Benutzerrechte um Download-Feature erweitert</item>
|
||||
<item>Ergebnisse sind jetzt auswählbar und exportierbar</item>
|
||||
<item>Layoutanpassung in Administration</item>
|
||||
<item>Problem behoben, bei dem die Abmelde-Links der Benachrichtigungen nicht richtig funktioniert haben</item>
|
||||
</entry>
|
||||
|
||||
<entry version="v2.2" date="2025-08-08">
|
||||
<item>Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis lieferten</item>
|
||||
<item>Filter für Fahrtrichtung des Fahrzeugs hinzugefügt</item>
|
||||
<item>Farbe der Treffsicherheit für bessere Lesbarkeit angepasst</item>
|
||||
<item>Treffsicherheit für Marke & Modell in Details hinzugefügt</item>
|
||||
<item>Layoutanpassung im Dashboard</item>
|
||||
</entry>
|
||||
|
||||
<entry version="v2.1" date="2025-07-16">
|
||||
<item><![CDATA[Umbenennung des Projekts in SEKT (<u>SE</u> <u>K</u>ennzeichenerfassungs<u>t</u>ool)]]></item>
|
||||
<item>Problem mit der automatischen Abmeldung behoben</item>
|
||||
<item>Hinweis nach dem Abmelden auf der Loginseite hinzugefügt</item>
|
||||
<item>Spalte mit dem Zeitpunkt der letzten Anmeldung des Benutzers in der Administration hinzugefügt</item>
|
||||
</entry>
|
||||
|
||||
<entry version="v2.0" date="2025-07-04">
|
||||
<item>Überarbeitetes Benutzerinterface</item>
|
||||
<item>Benutzerlogin hinzugefügt</item>
|
||||
<item>Benutzerrollen hinzugefügt</item>
|
||||
<item>
|
||||
Neue Funktionen für Admins hinzugefügt
|
||||
<subitem>Neue Benutzer hinzufügen</subitem>
|
||||
<subitem>Neues Passwort generieren</subitem>
|
||||
<subitem>Zugang sperren</subitem>
|
||||
<subitem>Zugang einschränken</subitem>
|
||||
<subitem>Benutzer bearbeiten</subitem>
|
||||
<subitem>Benutzer löschen</subitem>
|
||||
</item>
|
||||
<item>Beschränkung der Ergebnisse auf einen festgelegten Zeitraum</item>
|
||||
<item>Beschränkung der Ergebnisse für eine festgelegte Kamera</item>
|
||||
<item>Benachrichtigungen per E-Mail mit benutzerdefinierten Regeln</item>
|
||||
<item>Automatische Abmeldung nach 5 Minuten Inaktivität</item>
|
||||
</entry>
|
||||
|
||||
<entry version="v1.1" date="2025-06-18">
|
||||
<item>Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis lieferte</item>
|
||||
</entry>
|
||||
|
||||
<entry version="v1.0" date="2025-06-17">
|
||||
<item>Erster Release</item>
|
||||
</entry>
|
||||
</changelog>
|
||||
13
frontend/data/downloads.json
Normal file
13
frontend/data/downloads.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"firmwares": [
|
||||
{
|
||||
"version": "6.1",
|
||||
"date": "2024-10-03",
|
||||
"url": "/assets/downloads/firmware/CatchCAM2/6.1/CatchCAM2-europe-6.1-20241003-1123.sys",
|
||||
"releaseNotesUrl": "/assets/downloads/firmware/CatchCAM2/6.1/Release notes CatchCAM 2MP 6.1.pdf"
|
||||
}
|
||||
],
|
||||
"documents": [
|
||||
{ "title": "CatchCAM 2MP User Manual", "url": "/assets/downloads/docs/CatchCAM2/6.0/CatchCAM 2MP User Manual 6.0.pdf", "lang": "en", "kind": "Manual" }
|
||||
]
|
||||
}
|
||||
52
frontend/package-lock.json
generated
52
frontend/package-lock.json
generated
@ -14,7 +14,7 @@
|
||||
"@preline/select": "^3.1.0",
|
||||
"@preline/theme-switch": "^3.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"clsx": "^2.1.1",
|
||||
@ -22,6 +22,7 @@
|
||||
"datatables.net-dt": "^2.3.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dropzone": "^6.0.0-beta.2",
|
||||
"fast-xml-parser": "^5.3.1",
|
||||
"framer-motion": "^12.23.0",
|
||||
"https": "^1.0.0",
|
||||
"jquery": "^3.7.1",
|
||||
@ -1690,6 +1691,7 @@
|
||||
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@ -1757,6 +1759,7 @@
|
||||
"integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.33.1",
|
||||
"@typescript-eslint/types": "8.33.1",
|
||||
@ -2245,6 +2248,7 @@
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -2685,10 +2689,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@ -3252,6 +3257,7 @@
|
||||
"integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -3426,6 +3432,7 @@
|
||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
@ -3723,6 +3730,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.1.tgz",
|
||||
"integrity": "sha512-jbNkWiv2Ec1A7wuuxk0br0d0aTMUtQ4IkL+l/i1r9PRf6pLXjDgsBsWwO+UyczmQlnehi4Tbc8/KIvxGQe+I/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@ -5774,6 +5799,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -5793,6 +5819,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@ -6400,6 +6427,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@ -6454,7 +6493,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
|
||||
"integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.2",
|
||||
@ -6528,6 +6568,7 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -6677,6 +6718,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
"@preline/select": "^3.1.0",
|
||||
"@preline/theme-switch": "^3.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"clsx": "^2.1.1",
|
||||
@ -24,6 +24,7 @@
|
||||
"datatables.net-dt": "^2.3.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dropzone": "^6.0.0-beta.2",
|
||||
"fast-xml-parser": "^5.3.1",
|
||||
"framer-motion": "^12.23.0",
|
||||
"https": "^1.0.0",
|
||||
"jquery": "^3.7.1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
frontend/public/assets/img/csv.svg
Normal file
17
frontend/public/assets/img/csv.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
15
frontend/public/assets/img/json.svg
Normal file
15
frontend/public/assets/img/json.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
15
frontend/public/assets/img/pdf.svg
Normal file
15
frontend/public/assets/img/pdf.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
@ -110,7 +110,7 @@ export default function LoginPage() {
|
||||
</h2>
|
||||
|
||||
{/* Logout-Grund Alert */}
|
||||
{logoutNotice && mapped ? (
|
||||
{logoutNotice && mapped && !error ? (
|
||||
<div className="relative">
|
||||
<Alert
|
||||
title={mapped.title}
|
||||
|
||||
333
frontend/src/app/(protected)/downloads/page.tsx
Normal file
333
frontend/src/app/(protected)/downloads/page.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
// src/app/downloads/page.tsx
|
||||
import Link from "next/link";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import ChecksumCopy from "@/app/components/ChecksumCopy";
|
||||
import crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
// optional: Seiten-Caching (Inhalte werden eh per mtime-Cache beschleunigt)
|
||||
export const revalidate = 60;
|
||||
|
||||
const publicDir = path.join(process.cwd(), "public");
|
||||
|
||||
// globaler Cache pro Prozess
|
||||
type CacheEntry = { sha256?: string; md5?: string; mtimeMs: number; size: number };
|
||||
|
||||
// 🔧 Global-Augmentation statt "as any"
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __checksumCache: Map<string, CacheEntry> | undefined;
|
||||
}
|
||||
|
||||
const checksumCache: Map<string, CacheEntry> =
|
||||
globalThis.__checksumCache ?? new Map<string, CacheEntry>();
|
||||
|
||||
globalThis.__checksumCache = checksumCache;
|
||||
|
||||
function urlToAbsolutePublicPath(url: string) {
|
||||
// nur lokale URLs wie "/assets/..." zulassen
|
||||
if (!url.startsWith("/")) return null;
|
||||
const clean = url.split("?")[0].replace(/^\/+/, ""); // Query entfernen, führende Slashes trimmen
|
||||
const abs = path.join(publicDir, clean);
|
||||
// Pfad-Traversal verhindern
|
||||
if (!abs.startsWith(publicDir)) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
function hashFile(absPath: string, algo: "sha256" | "md5"): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash(algo);
|
||||
const s = createReadStream(absPath);
|
||||
s.on("error", reject);
|
||||
s.on("data", (chunk) => h.update(chunk));
|
||||
s.on("end", () => resolve(h.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function computeChecksumsForUrl(url: string): Promise<{ sha256?: string; md5?: string }> {
|
||||
const abs = urlToAbsolutePublicPath(url);
|
||||
if (!abs) return {};
|
||||
|
||||
try {
|
||||
const st = await fs.stat(abs);
|
||||
const cached = checksumCache.get(abs);
|
||||
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
||||
return { sha256: cached.sha256, md5: cached.md5 };
|
||||
}
|
||||
|
||||
const [sha256, md5] = await Promise.all([hashFile(abs, "sha256"), hashFile(abs, "md5")]);
|
||||
checksumCache.set(abs, { sha256, md5, mtimeMs: st.mtimeMs, size: st.size });
|
||||
return { sha256, md5 };
|
||||
} catch {
|
||||
// Datei existiert nicht o.ä. → still zurückgeben
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function withAutoChecksums(fw: Firmware): Promise<Firmware> {
|
||||
// vorhandene Werte aus JSON priorisieren; fehlende werden ergänzt
|
||||
const needSha = !fw.checksumSha256;
|
||||
const needMd5 = !fw.checksumMD5;
|
||||
|
||||
if (!needSha && !needMd5) return fw;
|
||||
|
||||
const computed = await computeChecksumsForUrl(fw.url);
|
||||
return {
|
||||
...fw,
|
||||
checksumSha256: fw.checksumSha256 ?? computed.sha256,
|
||||
checksumMD5: fw.checksumMD5 ?? computed.md5,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Typen ---------- */
|
||||
type Firmware = {
|
||||
version: string;
|
||||
date: string; // ISO-Datum
|
||||
url: string;
|
||||
releaseNotesUrl?: string; // <-- NEU: Link zur PDF
|
||||
notes?: string; // (legacy, falls alte JSONs noch Text haben)
|
||||
checksumSha256?: string;
|
||||
checksumMD5?: string;
|
||||
};
|
||||
|
||||
type Doc = {
|
||||
title: string;
|
||||
url: string;
|
||||
lang?: string;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
type DownloadsFile = {
|
||||
firmwares: Firmware[];
|
||||
documents: Doc[];
|
||||
};
|
||||
|
||||
/* ---------- Utils ---------- */
|
||||
function cmpSemverDesc(a: string, b: string) {
|
||||
const pa = a.replace(/^v/i, "").split(".").map(Number);
|
||||
const pb = b.replace(/^v/i, "").split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const da = pa[i] ?? 0;
|
||||
const db = pb[i] ?? 0;
|
||||
if (da !== db) return db - da;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function fmtDateDE(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Metadata ---------- */
|
||||
export const metadata = {
|
||||
title: "Downloads – Kamera Firmware & Dokumente",
|
||||
description: "Lade die aktuelle Firmware und Handbücher für deine Kamera herunter.",
|
||||
};
|
||||
|
||||
/* ---------- Page (Server Component) ---------- */
|
||||
export default async function DownloadPage() {
|
||||
const filePath = path.join(process.cwd(), "data", "downloads.json");
|
||||
let data: DownloadsFile = { firmwares: [], documents: [] };
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as DownloadsFile;
|
||||
data = {
|
||||
firmwares: Array.isArray(parsed.firmwares) ? parsed.firmwares : [],
|
||||
documents: Array.isArray(parsed.documents) ? parsed.documents : [],
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("downloads.json konnte nicht gelesen werden:", err);
|
||||
}
|
||||
|
||||
const firmwaresWithHashes = await Promise.all(
|
||||
data.firmwares.map((fw) => withAutoChecksums(fw))
|
||||
);
|
||||
|
||||
const sorted = [...firmwaresWithHashes].sort((a, b) => cmpSemverDesc(a.version, b.version));
|
||||
const latest = sorted[0];
|
||||
const older = sorted.slice(1);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-neutral-100">Downloads</h1>
|
||||
<p className="mt-1 text-gray-600 dark:text-neutral-300">
|
||||
Hier findest du Firmware-Versionen für die Kamera sowie Handbücher und weitere Dokumente.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 2-Spalten-Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
{/* Firmware (links) */}
|
||||
<section aria-labelledby="fw-title" className="space-y-4">
|
||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<h2 id="fw-title" className="text-xl font-medium text-gray-900 dark:text-neutral-100">
|
||||
Firmware
|
||||
</h2>
|
||||
|
||||
{latest && (
|
||||
<Link
|
||||
href={latest.url}
|
||||
prefetch={false}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
download
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" /><path d="M8.25 11.25 12 15l3.75-3.75" /><path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Neueste Version herunterladen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-base font-medium text-gray-900 dark:text-neutral-100">Version {latest.version}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Veröffentlichungsdatum: {fmtDateDE(latest.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:mt-0">
|
||||
<Link
|
||||
href={latest.url}
|
||||
prefetch={false}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-neutral-700 px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-neutral-800"
|
||||
download
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" /><path d="M8.25 11.25 12 15l3.75-3.75" /><path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Release Notes Link (statt Text) */}
|
||||
{(latest.releaseNotesUrl || latest.notes) && (
|
||||
<p className="mt-3 text-sm">
|
||||
<Link
|
||||
href={latest.releaseNotesUrl ?? "#"}
|
||||
prefetch={false}
|
||||
target={latest.releaseNotesUrl ? "_blank" : undefined}
|
||||
rel={latest.releaseNotesUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1 text-emerald-700 hover:underline dark:text-emerald-400"
|
||||
>
|
||||
{/* kleines PDF-Icon */}
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
Release Notes (PDF)
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ⬇️ SHA/MD5: klickbar & kopierbar */}
|
||||
{(latest.checksumSha256 || latest.checksumMD5) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={latest.checksumSha256} className="w-full" />
|
||||
<ChecksumCopy label="MD5" value={latest.checksumMD5} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-300">Keine Firmware verfügbar.</p>
|
||||
)}
|
||||
|
||||
{/* Ältere Versionen */}
|
||||
{older.length > 0 && (
|
||||
<details className="rounded-lg border border-gray-200 dark:border-neutral-700 p-4 open:bg-gray-50 dark:open:bg-neutral-900/50">
|
||||
<summary className="cursor-pointer select-none text-sm text-gray-700 dark:text-neutral-300">
|
||||
Ältere Versionen anzeigen
|
||||
</summary>
|
||||
<ul className="mt-3 divide-y divide-gray-200 dark:divide-neutral-800">
|
||||
{older.map((fw) => (
|
||||
<li key={fw.version} className="py-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-neutral-100">Version {fw.version}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400">{fmtDateDE(fw.date)}</div>
|
||||
|
||||
{(fw.releaseNotesUrl || fw.notes) && (
|
||||
<div className="text-sm mt-1">
|
||||
<Link
|
||||
href={fw.releaseNotesUrl ?? "#"}
|
||||
prefetch={false}
|
||||
target={fw.releaseNotesUrl ? "_blank" : undefined}
|
||||
rel={fw.releaseNotesUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1 text-emerald-700 hover:underline dark:text-emerald-400"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
Release Notes (PDF)
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{(fw.checksumSha256 || fw.checksumMD5) && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={fw.checksumSha256} />
|
||||
<ChecksumCopy label="MD5" value={fw.checksumMD5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Link
|
||||
href={fw.url}
|
||||
prefetch={false}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-neutral-700 px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-neutral-800"
|
||||
download
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" /><path d="M8.25 11.25 12 15l3.75-3.75" /><path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dokumente (rechts) */}
|
||||
<section aria-labelledby="docs-title" className="space-y-4">
|
||||
<h2 id="docs-title" className="text-xl font-medium text-gray-900 dark:text-neutral-100">Dokumente</h2>
|
||||
|
||||
{data.documents.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-300">Keine Dokumente verfügbar.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{data.documents.map((doc) => (
|
||||
<article key={doc.url} className="rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-neutral-100">{doc.title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
{doc.kind ?? "Dokument"}{doc.lang ? ` • ${doc.lang.toUpperCase()}` : ""}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link href={doc.url} prefetch={false} className="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-neutral-700 px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-neutral-800" download>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" /><path d="M8.25 11.25 12 15l3.75-3.75" /><path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// app/(protected)/layout.tsx
|
||||
// /src/app/(protected)/layout.tsx
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getServerUser } from '@/lib/auth';
|
||||
@ -25,7 +25,7 @@ export default async function ProtectedLayout({
|
||||
<PageTransition>
|
||||
<Header />
|
||||
<main className="p-4 flex-1 flex flex-col">
|
||||
<Tabs isAdmin={user.isAdmin} />
|
||||
<Tabs isAdmin={user.isAdmin} canDownload={user.canDownload} />
|
||||
<Card className="mt-4 flex-grow flex flex-col">
|
||||
{children}
|
||||
</Card>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Button } from '../../components/Button';
|
||||
import Modal from '../../components/Modal';
|
||||
import Table from '../../components/Table';
|
||||
@ -191,8 +191,7 @@ const BRAND_OPTIONS: string[] = [
|
||||
"XPENG",
|
||||
"Zeekr",
|
||||
"Zhidou",
|
||||
"Andere",
|
||||
];
|
||||
].sort();
|
||||
|
||||
/* --------------------------------------------------------- */
|
||||
/* Haupt-Komponente */
|
||||
@ -217,6 +216,10 @@ export default function NotiticationsPage() {
|
||||
recipients: [''],
|
||||
});
|
||||
|
||||
const EMAIL_PATTERN = "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"; // simpel & robust genug
|
||||
|
||||
const formEl = useRef<HTMLFormElement>(null);
|
||||
|
||||
/* ------------- Regeln laden --------------- */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@ -281,6 +284,8 @@ export default function NotiticationsPage() {
|
||||
|
||||
/* ------------- Speichern (neu / edit) ---- */
|
||||
const handleSave = async () => {
|
||||
if (formEl.current && !formEl.current.reportValidity()) return;
|
||||
|
||||
const recipients = form.recipients
|
||||
.map(r => r.trim())
|
||||
.filter(Boolean);
|
||||
@ -360,11 +365,13 @@ export default function NotiticationsPage() {
|
||||
{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"
|
||||
className="flex-1 border rounded px-3 py-2 dark:bg-neutral-900 border-gray-200 dark:border-neutral-700"
|
||||
placeholder="user@example.com"
|
||||
value={mail}
|
||||
onChange={e => changeRecipient(idx, e.target.value)}
|
||||
type='email'
|
||||
required={!!mail.trim()}
|
||||
pattern={EMAIL_PATTERN}
|
||||
/>
|
||||
{idx === 0 ? (
|
||||
<Button
|
||||
@ -397,7 +404,7 @@ export default function NotiticationsPage() {
|
||||
Kennzeichen enthält
|
||||
</label>
|
||||
<input
|
||||
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
|
||||
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 border-gray-200 dark:border-neutral-700"
|
||||
value={form.licensePattern ?? ""}
|
||||
onChange={e =>
|
||||
setForm(f => ({
|
||||
@ -411,7 +418,7 @@ export default function NotiticationsPage() {
|
||||
<label className="block mb-1">Marke</label>
|
||||
<ComboBox
|
||||
id='input-rules-brand'
|
||||
items={BRAND_OPTIONS.sort()}
|
||||
items={BRAND_OPTIONS}
|
||||
value={form.brand ?? ''} // controlled value
|
||||
onChange={val =>
|
||||
setForm(f => ({ ...f, brand: val }))
|
||||
@ -422,7 +429,7 @@ export default function NotiticationsPage() {
|
||||
<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"
|
||||
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 border-gray-200 dark:border-neutral-700"
|
||||
value={form.model ?? ""}
|
||||
onChange={e =>
|
||||
setForm(f => ({ ...f, model: e.target.value }))
|
||||
@ -585,7 +592,9 @@ export default function NotiticationsPage() {
|
||||
saveButton
|
||||
onSave={handleSave}
|
||||
>
|
||||
{renderForm()}
|
||||
<form ref={formEl}>
|
||||
{renderForm()}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ---------- Modal: Regel bearbeiten ---- */}
|
||||
@ -597,7 +606,9 @@ export default function NotiticationsPage() {
|
||||
onSave={handleSave}
|
||||
maxWidth="max-w-2xl"
|
||||
>
|
||||
{renderForm()}
|
||||
<form ref={formEl}>
|
||||
{renderForm()}
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/(protected)/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
@ -201,31 +203,31 @@ export default function Dashboard() {
|
||||
|
||||
<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} >
|
||||
<ChartContainer title="Erkennungen der letzten 7 Tage" aspect={2}>
|
||||
<ChartBar data={lastSevenDaysData} />
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6">
|
||||
<ChartContainer title="Stündliche Erkennungen" height={250}>
|
||||
<ChartContainer title="Stündliche Erkennungen" aspect={2.4}>
|
||||
<ChartBar data={hourlyData} />
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<ChartContainer title="Erfasste Länder" height={300}>
|
||||
<ChartContainer title="Erfasste Länder" aspect={1}>
|
||||
<ChartPie data={countryData} />
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="col-span-4">
|
||||
<ChartContainer title="Top 10 erfasste Fahrzeugmarken" height={300}>
|
||||
<ChartContainer title="Top 10 erfasste Fahrzeugmarken" aspect={1.2}>
|
||||
<ChartPie data={brandData} />
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<ChartContainer title="Erkennungen pro Kamera" height={300}>
|
||||
<ChartContainer title="Erkennungen pro Kamera" aspect={1}>
|
||||
<ChartPie data={cameraData} />
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@ -1,46 +1,168 @@
|
||||
// /src/app/(protected)/results/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import RecognitionsTable from '@/app/components/RecognitionsTable';
|
||||
import RecognitionDetails from '@/app/components/RecognitionDetails';
|
||||
import ImageZoomModal from '@/app/components/ImageZoomModal';
|
||||
import Modal from '@/app/components/Modal';
|
||||
import { useSSE } from '@/app/components/SSEContext';
|
||||
import type { Recognition } from '@/types/plates';
|
||||
|
||||
const PANEL_ANIM_MS = 0;
|
||||
|
||||
export default function ResultsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { resetNewCount } = useSSE(); // aus deinem SSE-Context
|
||||
const { resetNewCount } = useSSE();
|
||||
|
||||
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',
|
||||
});
|
||||
// Auswahl & Zoom
|
||||
const [selected, setSelected] = useState<Recognition | null>(null);
|
||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||
|
||||
resetNewCount(); // Zähler im Kontext zurücksetzen
|
||||
// Breakpoint erkennen (xl = 1280px)
|
||||
const [isXL, setIsXL] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1280px)');
|
||||
const onChange = () => setIsXL(mq.matches);
|
||||
|
||||
onChange(); // Initialzustand setzen
|
||||
|
||||
// Typ für MediaQueryList mit Legacy-API-Unterstützung
|
||||
type MediaQueryListWithLegacy = MediaQueryList & {
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
};
|
||||
|
||||
const mqWithLegacy = mq as MediaQueryListWithLegacy;
|
||||
|
||||
if (typeof mq.addEventListener === 'function') {
|
||||
mq.addEventListener('change', onChange);
|
||||
} else if (typeof mqWithLegacy.addListener === 'function') {
|
||||
// Fallback für ältere Browser
|
||||
mqWithLegacy.addListener(onChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof mq.removeEventListener === 'function') {
|
||||
mq.removeEventListener('change', onChange);
|
||||
} else if (typeof mqWithLegacy.removeListener === 'function') {
|
||||
mqWithLegacy.removeListener(onChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Animations-States fürs Desktop-Panel
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Ein-/Ausblendlogik: nur anwenden, wenn wir tatsächlich ein XL-Panel haben
|
||||
useEffect(() => {
|
||||
if (!isXL) {
|
||||
// auf Mobile niemals das rechte Panel mounten
|
||||
setShowDetails(false);
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
setShowDetails(true);
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
} else if (showDetails) {
|
||||
setIsVisible(false);
|
||||
const t = setTimeout(() => setShowDetails(false), PANEL_ANIM_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [selected, showDetails, isXL]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/recognitions/reset-count', { method: 'POST', credentials: 'include' });
|
||||
resetNewCount();
|
||||
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
|
||||
---------------------------------------------------------------- */
|
||||
// Grid-Spalten: nur auf XL verdrängen wir die Tabelle
|
||||
const gridCols = useMemo(
|
||||
() =>
|
||||
isXL && showDetails
|
||||
? 'minmax(0, 3fr) minmax(0, 1fr)' // 3/4 | 1/4
|
||||
: 'minmax(0, 1fr) minmax(0, 0fr)', // volle Breite | ausgeblendet
|
||||
[isXL, showDetails]
|
||||
);
|
||||
|
||||
return (
|
||||
<RecognitionsTable
|
||||
initialSearch={initialSearch}
|
||||
initialPage={initialPage}
|
||||
resetNewMarkers={resetNewMarkers}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: gridCols,
|
||||
transition: 'grid-template-columns 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Tabelle links */}
|
||||
<div className="min-w-0">
|
||||
<RecognitionsTable
|
||||
initialSearch={initialSearch}
|
||||
initialPage={initialPage}
|
||||
resetNewMarkers={resetNewMarkers}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details-Panel nur auf XL */}
|
||||
{isXL && showDetails && (
|
||||
<div className="hidden xl:block overflow-hidden">
|
||||
<div
|
||||
className={[
|
||||
'h-full transform transition-all duration-300 ease-out',
|
||||
isVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-6',
|
||||
].join(' ')}
|
||||
style={{ willChange: 'transform, opacity' }}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{selected && (
|
||||
<div className="max-h-[calc(100vh-180px)] overflow-auto">
|
||||
<RecognitionDetails
|
||||
entry={selected}
|
||||
onImageClick={(src) => setFullscreenImage(src)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile-Modal: öffnet nur < xl */}
|
||||
{!isXL && selected && (
|
||||
<Modal
|
||||
open={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
title={`Details zum Kennzeichen ${selected.license}`}
|
||||
maxWidth="max-w-full sm:max-w-2xl"
|
||||
>
|
||||
{selected && (
|
||||
<div className="max-h-full">
|
||||
<RecognitionDetails
|
||||
entry={selected}
|
||||
onImageClick={(src) => setFullscreenImage(src)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Bild-Zoom (global) */}
|
||||
{fullscreenImage && (
|
||||
<ImageZoomModal src={fullscreenImage} onClose={() => setFullscreenImage(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
21
frontend/src/app/changelog.xml/route.ts
Normal file
21
frontend/src/app/changelog.xml/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'data', 'changelog.xml');
|
||||
const xml = await fs.readFile(filePath, 'utf8'); // <-- String statt Buffer
|
||||
return new NextResponse(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
// Optional: Caching-Header
|
||||
'Cache-Control': 'public, max-age=0, s-maxage=300',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ type User = {
|
||||
isAdmin: boolean;
|
||||
tokenExpiresAt?: number;
|
||||
lastLogin?: string | null;
|
||||
features?: ('DOWNLOADS')[];
|
||||
};
|
||||
|
||||
type AuthContextType = {
|
||||
@ -84,6 +85,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
isAdmin: data.isAdmin,
|
||||
tokenExpiresAt: data.tokenExpiresAt ?? null,
|
||||
lastLogin: data.lastLogin ?? null,
|
||||
features: data.features ?? [],
|
||||
};
|
||||
|
||||
setUser(mapped);
|
||||
|
||||
@ -100,7 +100,7 @@ const CameraList: React.FC<CameraListProps> = ({
|
||||
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">
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 items-center w-full">
|
||||
{/* Checkbox + Label */}
|
||||
<div className="relative flex items-start w-full p-4">
|
||||
<input
|
||||
@ -120,8 +120,8 @@ const CameraList: React.FC<CameraListProps> = ({
|
||||
|
||||
{/* DateRange - nur wenn ausgewählt */}
|
||||
{checked && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center">
|
||||
<div className="flex gap-2 w-full min-w-0">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<DatePicker
|
||||
id={id + camera}
|
||||
title="Unbegrenzt"
|
||||
@ -133,11 +133,12 @@ const CameraList: React.FC<CameraListProps> = ({
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
suppressInitialChange={true}
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Einzel-Reset-Button */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
|
||||
@ -4,13 +4,9 @@
|
||||
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;
|
||||
};
|
||||
|
||||
@ -22,12 +18,19 @@ export default function Card({
|
||||
}: 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={`h-full p-4 flex flex-col border border-gray-200 shadow-2xs rounded-xl overflow-hidden
|
||||
bg-neutral-50 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">
|
||||
<div
|
||||
className={[
|
||||
'mb-3 flex items-center justify-between gap-4',
|
||||
title
|
||||
? '-mx-4 -mt-4 px-4 py-3 bg-white dark:bg-neutral-700 border-b border-gray-200 dark:border-neutral-700'
|
||||
: ''
|
||||
].join(' ')}
|
||||
>
|
||||
{title && (
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
|
||||
@ -1,88 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import TimeLine from "./TimeLine";
|
||||
import TimeLineItem from "./TimeLineItem";
|
||||
import { useEffect, useState } from 'react';
|
||||
import TimeLine from './TimeLine';
|
||||
import TimeLineItem from './TimeLineItem';
|
||||
|
||||
type ChangeItem = { html: string; sub?: string[] };
|
||||
type ChangeEntry = { version: string; dateIso: string; dateLabel: string; items: ChangeItem[] };
|
||||
|
||||
function fmtDateDE(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'short' }).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function parseXml(xml: string): ChangeEntry[] {
|
||||
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
||||
const entries = Array.from(doc.getElementsByTagName('entry')).map((e) => {
|
||||
const version = e.getAttribute('version') ?? '';
|
||||
const dateIso = e.getAttribute('date') ?? '';
|
||||
const dateLabel = fmtDateDE(dateIso);
|
||||
|
||||
const items = Array.from(e.children)
|
||||
.filter((c) => c.tagName === 'item')
|
||||
.map((itemEl) => {
|
||||
// Haupttext: nur Text- und CDATA-Knoten zusammensetzen (Subitems ausblenden)
|
||||
const mainText = Array.from(itemEl.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE || n.nodeType === Node.CDATA_SECTION_NODE)
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const subs = Array.from(itemEl.getElementsByTagName('subitem')).map(
|
||||
(s) => (s.textContent ?? '').trim()
|
||||
);
|
||||
|
||||
return { html: mainText, sub: subs.length ? subs : undefined };
|
||||
});
|
||||
|
||||
return { version, dateIso, dateLabel, items };
|
||||
});
|
||||
|
||||
// Neueste zuerst
|
||||
entries.sort((a, b) => new Date(b.dateIso).getTime() - new Date(a.dateIso).getTime());
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function Changelog() {
|
||||
const [data, setData] = useState<ChangeEntry[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch('/changelog.xml', { cache: 'no-cache' })
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(r.statusText)))
|
||||
.then((txt) => {
|
||||
if (!alive) return;
|
||||
setData(parseXml(txt));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setErr(String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (err) {
|
||||
return <div className="text-sm text-red-600">Changelog konnte nicht geladen werden: {err}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="text-sm text-gray-500">Changelog wird geladen…</div>;
|
||||
}
|
||||
|
||||
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>
|
||||
{data.map((entry) => (
|
||||
<TimeLineItem key={`${entry.version}-${entry.dateIso}`} title={entry.version} date={entry.dateLabel}>
|
||||
<ul className="list-disc list-inside">
|
||||
{entry.items.map((it, idx) => (
|
||||
<li key={idx}>
|
||||
{/* HTML aus XML (z. B. <u>…</u>) ist erlaubt, da Datei aus eigenem Repo kommt */}
|
||||
<span dangerouslySetInnerHTML={{ __html: it.html }} />
|
||||
{it.sub && it.sub.length > 0 && (
|
||||
<ul className="list-disc list-inside pl-6">
|
||||
{it.sub.map((s, i) => (
|
||||
<li key={i}>{s}</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>
|
||||
</>
|
||||
*/
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</TimeLineItem>
|
||||
))}
|
||||
</TimeLine>
|
||||
);
|
||||
}
|
||||
|
||||
91
frontend/src/app/components/Checkbox.tsx
Normal file
91
frontend/src/app/components/Checkbox.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
// components/Checkbox.tsx
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, useEffect, useId, useRef } from 'react';
|
||||
|
||||
type CheckboxProps = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: React.ReactNode;
|
||||
indeterminate?: boolean;
|
||||
|
||||
// optional controlled/uncontrolled
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
className?: string; // extra Klassen fürs <input>
|
||||
labelClassName?: string; // extra Klassen fürs <label>
|
||||
containerClassName?: string; // extra Klassen fürs Wrapper <div>
|
||||
};
|
||||
|
||||
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
|
||||
return (value: T) => refs.forEach(r => {
|
||||
if (!r) return;
|
||||
if (typeof r === 'function') r(value);
|
||||
else (r as React.MutableRefObject<T | null>).current = value;
|
||||
});
|
||||
}
|
||||
|
||||
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
|
||||
{
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
indeterminate = false,
|
||||
checked,
|
||||
defaultChecked,
|
||||
onChange,
|
||||
disabled,
|
||||
className = '',
|
||||
labelClassName = '',
|
||||
containerClassName = '',
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const autoId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// indeterminate ist eine DOM-Property, kein HTML-Attribut
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
const baseInputClasses =
|
||||
'shrink-0 mt-0.5 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 ' +
|
||||
'checked:border-blue-500 disabled:opacity-50 disabled:pointer-events-none ' +
|
||||
'dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 ' +
|
||||
'dark:checked:border-blue-500 dark:focus:ring-offset-gray-800';
|
||||
|
||||
return (
|
||||
<div className={`flex items-start ${containerClassName}`}>
|
||||
<input
|
||||
ref={mergeRefs(inputRef, ref)}
|
||||
id={id ?? autoId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-checked={indeterminate ? 'mixed' : undefined}
|
||||
className={`${baseInputClasses} ${className}`}
|
||||
/>
|
||||
|
||||
{label != null && (
|
||||
<label
|
||||
htmlFor={id ?? autoId}
|
||||
className={`text-sm text-gray-500 ms-3 dark:text-neutral-400 ${labelClassName}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Checkbox;
|
||||
172
frontend/src/app/components/CheckboxGroup.tsx
Normal file
172
frontend/src/app/components/CheckboxGroup.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
// /src/app/components/CheckboxGroup.tsx
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type CheckboxGroupOption = {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type CheckboxGroupProps = {
|
||||
options: CheckboxGroupOption[];
|
||||
/** selected values; for single-select just pass an array with max. one value */
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** when false => Radio-Verhalten (ein Element auswählbar) */
|
||||
multiple?: boolean; // default: true
|
||||
orientation?: 'vertical' | 'horizontal'; // default: 'vertical'
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
idPrefix?: string; // for input ids / radio name
|
||||
progressByValue?: Record<string, number>; // 0..100
|
||||
disabled?: boolean; // default: false ← wird jetzt wirklich ausgewertet
|
||||
};
|
||||
|
||||
export default function CheckboxGroup({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
multiple = true,
|
||||
orientation = 'vertical',
|
||||
className,
|
||||
itemClassName,
|
||||
idPrefix = 'cbg',
|
||||
progressByValue,
|
||||
disabled = false, // ← Gruppen-Disable
|
||||
}: CheckboxGroupProps) {
|
||||
const horizontal = orientation === 'horizontal';
|
||||
|
||||
const containerClasses = clsx(
|
||||
horizontal ? 'flex flex-wrap gap-2' : 'grid grid-cols-1 gap-2',
|
||||
className
|
||||
);
|
||||
|
||||
const nameForRadios = React.useMemo(() => `${idPrefix}-name`, [idPrefix]);
|
||||
|
||||
// 👉 berücksichtigt jetzt das übergebene "groupDisabled"
|
||||
const handleToggle = (val: string, isItemDisabled?: boolean) => {
|
||||
if (disabled || isItemDisabled) return;
|
||||
if (multiple) {
|
||||
onChange(value.includes(val) ? value.filter(v => v !== val) : [...value, val]);
|
||||
} else {
|
||||
onChange(value[0] === val ? [] : [val]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
role="group"
|
||||
aria-disabled={disabled || undefined} // ← Gruppe ist deaktiviert
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const id = `${idPrefix}-${idx}`;
|
||||
const selected = value.includes(opt.value);
|
||||
const inputType = multiple ? 'checkbox' : 'radio';
|
||||
const pct = Math.max(0, Math.min(100, progressByValue?.[opt.value] ?? 0));
|
||||
const isDisabled = disabled || !!opt.disabled; // ← Gruppen- oder Item-Disable
|
||||
|
||||
return (
|
||||
<div key={opt.value} className={clsx(horizontal && 'basis-full sm:basis-auto')}>
|
||||
<input
|
||||
id={id}
|
||||
name={nameForRadios}
|
||||
type={inputType}
|
||||
checked={selected}
|
||||
onChange={() => handleToggle(opt.value, isDisabled)} // ← Guard oben
|
||||
disabled={isDisabled} // ← wirklich disabled
|
||||
className="sr-only peer"
|
||||
aria-disabled={isDisabled || undefined}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={clsx(
|
||||
'relative overflow-hidden', // Progress-Layer braucht das
|
||||
'block rounded-md border text-sm transition',
|
||||
'p-4 flex items-center gap-4 h-20',
|
||||
'bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700',
|
||||
!isDisabled && 'cursor-pointer hover:border-blue-600', // nur wenn aktiv
|
||||
'peer-checked:bg-blue-600 peer-checked:text-white peer-checked:border-blue-600',
|
||||
isDisabled && 'opacity-60 cursor-not-allowed', // ← visuelles Disable
|
||||
itemClassName, opt.className
|
||||
)}
|
||||
aria-busy={pct > 0 && pct < 100 ? true : undefined}
|
||||
>
|
||||
{/* Progress-Füllung unter dem Inhalt */}
|
||||
{pct > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
'absolute inset-y-0 left-0 z-0',
|
||||
'bg-emerald-800 dark:bg-emerald-600'
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inhaltsebene */}
|
||||
<div className="relative z-10 flex items-center gap-4 w-full">
|
||||
{opt.icon && (
|
||||
<span
|
||||
className="cbg-icon mt-0.5 shrink-0 h-8 w-8 transition
|
||||
[&>img]:h-full [&>img]:w-full [&>svg]:h-full [&>svg]:w-full"
|
||||
aria-hidden
|
||||
>
|
||||
{opt.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Linke Textspalte (Label + Description) */}
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="font-semibold leading-5 truncate">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span
|
||||
className={clsx(
|
||||
'text-xs leading-5',
|
||||
'peer-checked:text-zinc-200 dark:peer-checked:text-zinc-200',
|
||||
'truncate'
|
||||
)}
|
||||
title={typeof opt.description === 'string' ? opt.description : undefined}
|
||||
>
|
||||
{opt.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Rechte Spalte: Prozentanzeige */}
|
||||
{(pct > 0 && pct < 100) && (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-auto shrink-0 tabular-nums text-md font-medium',
|
||||
'text-zinc-200',
|
||||
'peer-checked:text-white/90'
|
||||
)}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
{pct === 100 && (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-auto shrink-0 text-xs font-medium',
|
||||
'text-emerald-700 dark:text-emerald-300',
|
||||
'peer-checked:text-white/90'
|
||||
)}
|
||||
>
|
||||
100%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/components/ChecksumCopy.tsx
Normal file
82
frontend/src/app/components/ChecksumCopy.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
/** Breite der Label-Spalte (Tailwind Klasse) */
|
||||
labelWidthClass?: string; // z.B. "w-20 sm:w-24"
|
||||
};
|
||||
|
||||
export default function ChecksumCopy({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
labelWidthClass = "w-12",
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
if (!value) return null;
|
||||
|
||||
async function copy() {
|
||||
if (!value) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`${label}-Wert kopieren`}
|
||||
className={[
|
||||
"relative group w-full flex items-center gap-2 text-[11px] leading-5",
|
||||
"text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-neutral-100",
|
||||
"cursor-pointer",
|
||||
className ?? ""
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Overlay „Kopiert!“ */}
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={[
|
||||
"pointer-events-none absolute left-1/2 -translate-x-1/2 -translate-y-full",
|
||||
"-top-1.5 z-10 rounded px-2 py-0.5 text-[10px] font-medium",
|
||||
"bg-emerald-600 text-white shadow-sm border border-emerald-700",
|
||||
"transition-opacity duration-200",
|
||||
copied ? "opacity-100" : "opacity-0"
|
||||
].join(" ")}
|
||||
>
|
||||
Kopiert!
|
||||
</span>
|
||||
|
||||
{/* fixe Label-Spalte */}
|
||||
<span className={`${labelWidthClass} shrink-0 text-left underline decoration-dotted underline-offset-2`}>
|
||||
{label}:
|
||||
</span>
|
||||
|
||||
{/* Hash füllt die Mitte */}
|
||||
<code className="flex-1 break-all rounded px-1 py-0.5 bg-gray-100 dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 text-left">
|
||||
{value}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -20,7 +20,7 @@ export default function ConnectionIndicator() {
|
||||
};
|
||||
|
||||
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]}`}>
|
||||
<div className={`w-full sm:w-auto text-center p-2 rounded-xl shadow-xs text-sm bg-white dark:bg-neutral-800 dark:shadow-neutral-700/70 ${colorMap[connectionStatus]}`}>
|
||||
● {labelMap[connectionStatus]}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ type Props = {
|
||||
title: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
onDateChange: (range: { from: Date | null; to: Date | null }) => void;
|
||||
selectionDatesMode?: 'single' | 'multiple' | 'multiple-ranged';
|
||||
disableUnavailableDates?: boolean;
|
||||
@ -58,6 +59,7 @@ export default function DatePicker({
|
||||
title,
|
||||
value,
|
||||
className = '',
|
||||
containerClassName,
|
||||
selectionDatesMode = 'multiple-ranged',
|
||||
disableUnavailableDates = true,
|
||||
onDateChange,
|
||||
@ -298,7 +300,7 @@ export default function DatePicker({
|
||||
}, [calendarInstance, onReset, onDateChange]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex items-center gap-2 w-full ${containerClassName ?? ''}`}>
|
||||
<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">
|
||||
|
||||
24
frontend/src/app/components/Field.tsx
Normal file
24
frontend/src/app/components/Field.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
// components/Field.tsx
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("w-full", className)}>
|
||||
<label htmlFor={htmlFor} className="block text-sm font-medium mb-2 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,69 @@
|
||||
// /src/app/components/Footer.tsx
|
||||
'use client';
|
||||
|
||||
import Changelog from "./Changelog";
|
||||
import DarkModeToggle from "./DarkModeToggle";
|
||||
import Modal from "./Modal";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Footer() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const tryFetch = async (url: string) => {
|
||||
const res = await fetch(url, { cache: 'no-cache', headers: { Accept: 'application/xml,text/xml,*/*' } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return res.text();
|
||||
};
|
||||
|
||||
const parseLatestVersion = (xml: string) => {
|
||||
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
||||
const parserError = doc.querySelector('parsererror');
|
||||
if (parserError) throw new Error('XML parse error');
|
||||
|
||||
// Sammle alle <entry version="" date="...">
|
||||
const entries = Array.from(doc.getElementsByTagName('entry')).map(e => ({
|
||||
version: e.getAttribute('version') ?? '',
|
||||
dateIso: e.getAttribute('date') ?? ''
|
||||
}));
|
||||
|
||||
// Falls keine <entry> vorhanden: versuche <release> oder <version>-Tag
|
||||
if (entries.length === 0) {
|
||||
const rel = doc.querySelector('release');
|
||||
const vAttr = rel?.getAttribute('version');
|
||||
const vNode = vAttr || doc.querySelector('version')?.textContent?.trim();
|
||||
return vNode || null;
|
||||
}
|
||||
|
||||
// Sortiere wie in Changelog.tsx nach Datum (neueste zuerst)
|
||||
entries.sort((a, b) => new Date(b.dateIso).getTime() - new Date(a.dateIso).getTime());
|
||||
const latest = entries[0]?.version?.trim();
|
||||
return latest || null;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 1) bevorzugt dein ursprünglicher Pfad
|
||||
const xml = await tryFetch('/data/changelog.xml').catch(async () => {
|
||||
// 2) Fallback wie in Changelog.tsx
|
||||
return tryFetch('/changelog.xml');
|
||||
});
|
||||
|
||||
const v = parseLatestVersion(xml);
|
||||
if (!alive) return;
|
||||
setVersion(v ? (v.startsWith('v') || v.startsWith('V') ? v : `v${v}`) : 'v?');
|
||||
} catch (e) {
|
||||
console.error('Version laden fehlgeschlagen:', e);
|
||||
if (!alive) setVersion(null);
|
||||
else setVersion('v?');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { alive = 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">
|
||||
@ -15,21 +72,33 @@ export default function Footer() {
|
||||
<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>
|
||||
|
||||
{/* Version: dynamisch aus changelog.xml */}
|
||||
{version && (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer p-1 hover:underline hover:text-gray-800 dark:hover:text-neutral-200"
|
||||
aria-label="Changelog öffnen"
|
||||
>
|
||||
{version}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
37
frontend/src/app/components/FormSection.tsx
Normal file
37
frontend/src/app/components/FormSection.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
// components/FormSection.tsx
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function FormSection({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
bodyClassName?: string; // ⬅️ NEW
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const id = `sec-${title.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={id}
|
||||
className={clsx(
|
||||
"rounded-lg border border-gray-200 dark:border-neutral-700 p-4",
|
||||
"flex flex-col", // ⬅️ let the body grow
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h2 id={id} className="text-sm font-semibold text-gray-900 dark:text-neutral-100">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 dark:text-neutral-400 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={clsx("flex-1", bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { useCurrentUser } from './AuthContext';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import { useSSE } from './SSEContext';
|
||||
import { useEffect } from 'react';
|
||||
import DownloadPage from '../(protected)/downloads/page';
|
||||
|
||||
export default function HomeClient() {
|
||||
const { loading } = useCurrentUser();
|
||||
@ -40,6 +41,7 @@ export default function HomeClient() {
|
||||
{tabFromPath === 'dashboard' && <Dashboard />}
|
||||
{tabFromPath === 'results' && <ResultsPage />}
|
||||
{tabFromPath === 'notifications' && <NotiticationsPage />}
|
||||
{tabFromPath === 'downloads' && <DownloadPage />}
|
||||
{tabFromPath === 'admin' && <Administration />}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,19 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type LoadingSpinnerProps = {
|
||||
showBackground?: boolean;
|
||||
showBorder?: boolean;
|
||||
/** kleiner Spinner ohne äußeres Layout – z.B. im Button */
|
||||
inline?: boolean;
|
||||
/** Größe des Spinners */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Zusätzliche Klassen (z.B. text-current) */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LoadingSpinner({ showBackground = false, showBorder = false }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'size-4 border-2',
|
||||
md: 'size-6 border-3',
|
||||
lg: 'size-8 border-4',
|
||||
};
|
||||
|
||||
export default function LoadingSpinner({
|
||||
showBackground = false,
|
||||
showBorder = false,
|
||||
inline = false,
|
||||
size = 'md',
|
||||
className,
|
||||
}: LoadingSpinnerProps) {
|
||||
// INLINE: nur der Kreis – perfekt für Buttons/Labels
|
||||
if (inline) {
|
||||
return (
|
||||
<span className={clsx('inline-flex items-center', className)}>
|
||||
<span
|
||||
className={clsx(
|
||||
'animate-spin inline-block rounded-full border-current border-t-transparent',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
>
|
||||
<span className="sr-only">Lädt...</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// BLOCK: dein bisheriges Layout
|
||||
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',
|
||||
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(' ');
|
||||
@ -22,7 +58,11 @@ export default function LoadingSpinner({ showBackground = false, showBorder = fa
|
||||
<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"
|
||||
className={clsx(
|
||||
'animate-spin inline-block rounded-full border-current border-t-transparent',
|
||||
'text-blue-600 dark:text-blue-500',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// Modal.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from './Button';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
@ -10,8 +12,9 @@ type ModalProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
saveButton?: boolean;
|
||||
onSave?: () => void;
|
||||
onSave?: () => void | Promise<void>;
|
||||
maxWidth?: string;
|
||||
busy?: boolean;
|
||||
};
|
||||
|
||||
export default function Modal({
|
||||
@ -22,35 +25,83 @@ export default function Modal({
|
||||
saveButton = false,
|
||||
onSave,
|
||||
maxWidth,
|
||||
busy = false,
|
||||
}: ModalProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const blocked = saving || busy;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape' && !blocked) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
}, [onClose, blocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSaving(false);
|
||||
}, [open]);
|
||||
|
||||
// Modal.tsx – Ergänzung
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const body = document.body;
|
||||
const docEl = document.documentElement;
|
||||
|
||||
// Zustände merken
|
||||
const prevOverflow = body.style.overflow;
|
||||
const prevPaddingRight = body.style.paddingRight;
|
||||
const prevBehavior = docEl.style.overscrollBehavior as string;
|
||||
|
||||
// Breite der Scrollbar ermitteln (zur Layout-Stabilisierung)
|
||||
const scrollbarWidth = window.innerWidth - docEl.clientWidth;
|
||||
|
||||
body.style.overflow = 'hidden';
|
||||
if (scrollbarWidth > 0) {
|
||||
body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
}
|
||||
|
||||
// verhindert iOS/Android "bounce" ins Dokument
|
||||
docEl.style.overscrollBehavior = 'contain';
|
||||
|
||||
return () => {
|
||||
body.style.overflow = prevOverflow;
|
||||
body.style.paddingRight = prevPaddingRight;
|
||||
docEl.style.overscrollBehavior = prevBehavior;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSaveClick = useCallback(async () => {
|
||||
if (!onSave || blocked) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
await onSave();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [onSave, blocked]);
|
||||
|
||||
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"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 sm:px-0 overscroll-contain"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-busy={blocked || undefined}
|
||||
>
|
||||
<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">
|
||||
<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 mit TableHead-Background */}
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-gray-200 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-700">
|
||||
<h2 id="modal-title" className="text-lg font-bold text-gray-900 dark:text-neutral-100">
|
||||
{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"
|
||||
onClick={() => { if (!blocked) onClose(); }}
|
||||
disabled={blocked}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-600 disabled:opacity-50 disabled:cursor-not-allowed text-gray-500 dark:text-neutral-300 cursor-pointer"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@ -62,28 +113,40 @@ export default function Modal({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 overflow-y-auto text-sm text-gray-700 dark:text-neutral-300">
|
||||
<div className={`p-4 overflow-y-auto text-sm text-gray-700 dark:text-neutral-300 ${blocked ? 'cursor-wait' : ''}`}>
|
||||
{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'
|
||||
onClick={() => { if (!blocked) onClose(); }}
|
||||
color="white"
|
||||
size="small"
|
||||
variant="solid"
|
||||
disabled={blocked}
|
||||
>
|
||||
❌ Schließen
|
||||
</Button>
|
||||
|
||||
{saveButton && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
color='teal'
|
||||
size='small'
|
||||
variant='solid'
|
||||
onClick={handleSaveClick}
|
||||
color="teal"
|
||||
size="small"
|
||||
variant="solid"
|
||||
disabled={blocked}
|
||||
aria-busy={blocked || undefined}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
💾 Speichern
|
||||
{blocked ? (
|
||||
<>
|
||||
<LoadingSpinner inline size="sm" className="text-current" />
|
||||
Speichern…
|
||||
</>
|
||||
) : (
|
||||
<>💾 Speichern</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Pagination.tsx
|
||||
// /src/app/components/Pagination.tsx
|
||||
'use client';
|
||||
|
||||
import {
|
||||
@ -66,7 +66,7 @@ export function Pagination({
|
||||
${
|
||||
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'
|
||||
: 'text-gray-800 hover:bg-black/10 dark:text-white dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{tok}
|
||||
@ -98,7 +98,7 @@ export function Pagination({
|
||||
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"
|
||||
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-black/10 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<ChevronsLeft className="size-3.5 shrink-0" />
|
||||
</button>
|
||||
@ -108,7 +108,7 @@ export function Pagination({
|
||||
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"
|
||||
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-black/10 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 shrink-0" />
|
||||
</button>
|
||||
@ -121,7 +121,7 @@ export function Pagination({
|
||||
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"
|
||||
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-black/10 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</button>
|
||||
@ -131,7 +131,7 @@ export function Pagination({
|
||||
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"
|
||||
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-black/10 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<ChevronsRight className="size-3.5 shrink-0" />
|
||||
</button>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// components/RecognitionDetails.tsx
|
||||
|
||||
// /src/app/components/RecognitionDetails.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
@ -14,18 +13,50 @@ type Props = {
|
||||
onImageClick: (src: string) => void;
|
||||
};
|
||||
|
||||
export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
|
||||
export default function RecognitionDetails({
|
||||
entry,
|
||||
onImageClick,
|
||||
}: Props) {
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [plateImgLoaded, setPlateImgLoaded] = useState(false);
|
||||
|
||||
// id ändert sich bei jeder Auswahl
|
||||
// Breakpoint: xl (≥ 1280px)
|
||||
const [isXL, setIsXL] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1280px)');
|
||||
const onChange = () => setIsXL(mq.matches);
|
||||
|
||||
onChange(); // Initialzustand setzen
|
||||
|
||||
// Typ für MediaQueryList mit Legacy-API-Unterstützung
|
||||
type MediaQueryListWithLegacy = MediaQueryList & {
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
};
|
||||
|
||||
const mqWithLegacy = mq as MediaQueryListWithLegacy;
|
||||
|
||||
if (typeof mq.addEventListener === 'function') {
|
||||
mq.addEventListener('change', onChange);
|
||||
} else if (typeof mqWithLegacy.addListener === 'function') {
|
||||
// Fallback für ältere Browser
|
||||
mqWithLegacy.addListener(onChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof mq.removeEventListener === 'function') {
|
||||
mq.removeEventListener('change', onChange);
|
||||
} else if (typeof mqWithLegacy.removeListener === 'function') {
|
||||
mqWithLegacy.removeListener(onChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => setImgLoaded(false), [entry.id]);
|
||||
useEffect(() => setPlateImgLoaded(false), [entry.id]);
|
||||
|
||||
return (
|
||||
<Card title={`Details zum Kennzeichen ${entry.license}`}>
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative inline-block mb-3">
|
||||
<Image
|
||||
src={`/images/${entry.imageFile}`}
|
||||
@ -37,8 +68,6 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
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} />
|
||||
@ -49,7 +78,7 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
<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}`}
|
||||
@ -60,8 +89,6 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
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} />
|
||||
@ -71,7 +98,7 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
|
||||
<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>
|
||||
|
||||
@ -83,7 +110,7 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
|
||||
<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>
|
||||
|
||||
@ -102,20 +129,15 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
transform: `rotate(${entry.directionDegrees}deg)`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
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 className="ml-3 text-sm">
|
||||
{entry.direction === 'Away' ? 'abfahrend'
|
||||
: entry.direction === 'Towards' ? 'ankommend'
|
||||
: '–'}
|
||||
</div>
|
||||
</div>
|
||||
@ -126,6 +148,15 @@ export default function RecognitionDetails({ entry, onImageClick }: Props) {
|
||||
<div className="col-span-2 font-semibold">Zeit (UTC):</div>
|
||||
<div className="col-span-2">{new Date(entry.timestampUTC).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Nur auf großen Screens in eine Card packen
|
||||
return isXL ? (
|
||||
<Card title={`Details zum Kennzeichen ${entry.license}`}>
|
||||
{content}
|
||||
</Card>
|
||||
) : (
|
||||
<>{content}</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,44 +3,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Progress from './Progress';
|
||||
import { Recognition } from '../../types/plates';
|
||||
import Checkbox from './Checkbox';
|
||||
|
||||
type Props = {
|
||||
entry: Recognition;
|
||||
isSelected: boolean;
|
||||
isNew: boolean;
|
||||
onClick: () => void;
|
||||
isSelected?: boolean;
|
||||
isNew?: boolean;
|
||||
onClick?: () => void;
|
||||
|
||||
// neu für Checkbox:
|
||||
checked?: boolean;
|
||||
onToggle?: () => void;
|
||||
};
|
||||
|
||||
export default function RecognitionRow({ entry, isSelected, isNew, onClick }: Props) {
|
||||
export default function RecognitionRow({
|
||||
entry,
|
||||
isSelected = false,
|
||||
isNew = false,
|
||||
onClick,
|
||||
checked = false,
|
||||
onToggle,
|
||||
}: Props) {
|
||||
const [animatedConfidence, setAnimatedConfidence] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
setAnimatedConfidence(0);
|
||||
setTimeout(() => {
|
||||
setAnimatedConfidence(entry.confidence ?? 0);
|
||||
}, 50); // Trigger animation
|
||||
setTimeout(() => setAnimatedConfidence(entry.confidence ?? 0), 50);
|
||||
} 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' : '';
|
||||
const rowClass = [
|
||||
'cursor-pointer',
|
||||
isSelected && !isNew ? 'bg-gray-200 dark:bg-neutral-700' : '',
|
||||
!isSelected ? 'hover:bg-gray-100 dark:hover:bg-neutral-600' : '',
|
||||
isNew ? 'bg-green-50 dark:bg-green-600' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const showDirIcon =
|
||||
typeof entry.directionDegrees === 'number' && entry.directionDegrees > 0;
|
||||
|
||||
const dirText = entry.direction
|
||||
? entry.direction.toLowerCase() === 'away'
|
||||
? 'abfahrend'
|
||||
: entry.direction.toLowerCase() === 'towards'
|
||||
? 'ankommend'
|
||||
: '–'
|
||||
: '–';
|
||||
|
||||
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">
|
||||
<tr onClick={onClick} className={rowClass}>
|
||||
{/* 1) Checkbox-Spalte */}
|
||||
<td
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
id={`row-${entry.id}`}
|
||||
checked={checked}
|
||||
// indeterminate brauchst du auf Zeilenebene nicht → weglassen oder false
|
||||
onChange={() => onToggle?.()}
|
||||
label={
|
||||
<span className="sr-only">
|
||||
Zeile für {entry.licenseFormatted ?? entry.license} auswählen
|
||||
</span>
|
||||
}
|
||||
containerClassName="justify-center"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* 2) restliche Spalten – Anzahl muss zum Head passen */}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-neutral-200">
|
||||
{entry.licenseFormatted ?? entry.license}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{entry.country ?? '–'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{entry.brand ?? '–'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{entry.model ?? '–'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm 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 && (
|
||||
{/* Richtung hat im Head colSpan={2} → hier 2 Zellen */}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{showDirIcon && (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
@ -49,28 +101,21 @@ export default function RecognitionRow({ entry, isSelected, isNew, onClick }: Pr
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
transform: `rotate(${entry.directionDegrees}deg)`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
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 className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{dirText}
|
||||
</td>
|
||||
<td className="p-3 text-gray-600 dark:text-neutral-300">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm 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>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
|
||||
{entry.cameraName ?? '–'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,72 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } 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';
|
||||
import Checkbox from './Checkbox';
|
||||
import { Button } from './Button';
|
||||
import Modal from './Modal';
|
||||
import CheckboxGroup from './CheckboxGroup';
|
||||
import Image from 'next/image';
|
||||
|
||||
type Props = {
|
||||
initialSearch: string;
|
||||
initialPage: number;
|
||||
resetNewMarkers?: boolean;
|
||||
|
||||
/** Optional: von außen gesteuerte Auswahl */
|
||||
selected?: Recognition | null;
|
||||
onSelect?: (rec: Recognition | null) => void;
|
||||
};
|
||||
|
||||
export default function RecognitionsTable({ resetNewMarkers, initialPage, initialSearch }: Props) {
|
||||
// Welche Keys sind exportierbar:
|
||||
type ExportFieldKey =
|
||||
| 'id'
|
||||
| 'license'
|
||||
| 'licenseFormatted'
|
||||
| 'country'
|
||||
| 'brand'
|
||||
| 'model'
|
||||
| 'confidence'
|
||||
| 'timestampLocal'
|
||||
| 'cameraName'
|
||||
| 'direction'
|
||||
| 'directionDegrees';
|
||||
|
||||
const EXPORT_FIELDS: { key: ExportFieldKey; label: string }[] = [
|
||||
{ key: 'licenseFormatted', label: 'Kennzeichen (formatiert)' },
|
||||
{ key: 'license', label: 'Kennzeichen (roh)' },
|
||||
{ key: 'country', label: 'Land' },
|
||||
{ key: 'brand', label: 'Marke' },
|
||||
{ key: 'model', label: 'Modell' },
|
||||
{ key: 'confidence', label: 'Treffsicherheit' },
|
||||
{ key: 'timestampLocal', label: 'Zeit' },
|
||||
{ key: 'cameraName', label: 'Kamera' },
|
||||
{ key: 'direction', label: 'Richtung' },
|
||||
{ key: 'directionDegrees', label: 'Richtung (°)' },
|
||||
{ key: 'id', label: 'ID' },
|
||||
];
|
||||
|
||||
const HIDDEN_FROM_GRID: ExportFieldKey[] = ['directionDegrees'];
|
||||
|
||||
const LABELS: Record<ExportFieldKey, string> =
|
||||
Object.fromEntries(EXPORT_FIELDS.map(f => [f.key, f.label])) as Record<ExportFieldKey, string>;
|
||||
|
||||
function applyCoupling(keys: ExportFieldKey[]): ExportFieldKey[] {
|
||||
const s = new Set<ExportFieldKey>(keys);
|
||||
if (s.has('direction')) s.add('directionDegrees');
|
||||
else s.delete('directionDegrees');
|
||||
return Array.from(s);
|
||||
}
|
||||
|
||||
export default function RecognitionsTable({
|
||||
resetNewMarkers,
|
||||
initialPage,
|
||||
initialSearch,
|
||||
selected,
|
||||
onSelect,
|
||||
}: 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 [cameraFilter, setCameraFilter] = useState<string>(''); // '' = alle
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const { onNewRecognition } = useSSE();
|
||||
const [exportRunning, setExportRunning] = useState(false);
|
||||
const [exportStage, setExportStage] = useState<string>('');
|
||||
const [exportCounts, setExportCounts] = useState<{done:number,total:number}|null>(null);
|
||||
const [exportProgress, setExportProgress] = useState<number>(0);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
// controlled/uncontrolled selection
|
||||
const [internalSelected, setInternalSelected] = useState<Recognition | null>(null);
|
||||
const controlled = typeof onSelect === 'function' || selected !== undefined;
|
||||
const selectedRow = controlled ? (selected ?? null) : internalSelected;
|
||||
const setSelectedRow = controlled ? (onSelect as (r: Recognition | null) => void) : setInternalSelected;
|
||||
|
||||
const selectAllFields = () =>
|
||||
setExportFields(applyCoupling(EXPORT_FIELDS.map(f => f.key)));
|
||||
|
||||
const DEFAULT_EXPORT_FIELDS: ExportFieldKey[] = [
|
||||
'licenseFormatted','country','brand','model',
|
||||
'confidence','timestampLocal','cameraName','direction'
|
||||
];
|
||||
|
||||
const selectDefault = () =>
|
||||
setExportFields(applyCoupling(DEFAULT_EXPORT_FIELDS));
|
||||
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json' | 'pdf'>('csv');
|
||||
const [exportFields, setExportFields] = useState<ExportFieldKey[]>(DEFAULT_EXPORT_FIELDS);
|
||||
|
||||
const onToggleFieldCoupled = (key: ExportFieldKey, checked: boolean) => {
|
||||
setExportFields(prev => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(key); else next.delete(key);
|
||||
return applyCoupling(Array.from(next));
|
||||
});
|
||||
};
|
||||
|
||||
const [allMatchingSelected, setAllMatchingSelected] = useState(false);
|
||||
const [deselectedIds, setDeselectedIds] = useState<Set<number>>(new Set());
|
||||
const [totalMatching, setTotalMatching] = useState<number>(0);
|
||||
|
||||
const masterRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { onNewRecognition, onExportProgress } = useSSE();
|
||||
const exportJobIdRef = useRef<string | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const itemsPerPage = 10;
|
||||
const pageStart = totalMatching === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const pageEnd = Math.min(currentPage * itemsPerPage, totalMatching);
|
||||
|
||||
// Lese Query-Parameter aus URL
|
||||
/** Icons */
|
||||
const CsvImg = (
|
||||
<Image src="/assets/img/csv.svg" alt="CSV" width={32} height={32} className="h-8 w-8 object-contain" />
|
||||
);
|
||||
const JsonImg = (
|
||||
<Image src="/assets/img/json.svg" alt="JSON" width={32} height={32} className="h-8 w-8 object-contain" />
|
||||
);
|
||||
const PdfImg = (
|
||||
<Image src="/assets/img/pdf.svg" alt="PDF" width={32} height={32} className="h-8 w-8 object-contain" />
|
||||
);
|
||||
|
||||
// Auswahl bei Filterwechsel leeren (optional)
|
||||
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');
|
||||
setSelectedIds(new Set());
|
||||
}, [searchTerm, dateRange, directionFilter]);
|
||||
|
||||
// SSE-Listener für Export-Fortschritt
|
||||
useEffect(() => {
|
||||
if (!onExportProgress) return;
|
||||
const off = onExportProgress((msg: { jobId?: string; stage?: string; done?: number; total?: number; progress?: number }) => {
|
||||
if (!msg || msg.jobId !== exportJobIdRef.current) return;
|
||||
setExportCounts({ done: msg.done ?? 0, total: msg.total ?? 0 });
|
||||
if (msg.stage) setExportStage(msg.stage);
|
||||
|
||||
if (typeof msg.progress === 'number') {
|
||||
setExportProgress(Math.max(1, Math.min(99, Math.round(msg.progress))));
|
||||
} else if ((msg.done ?? 0) > 0 && (msg.total ?? 0) > 0) {
|
||||
const p = Math.round(((msg.done as number) / (msg.total as number)) * 98);
|
||||
setExportProgress(Math.max(1, Math.min(99, p)));
|
||||
}
|
||||
});
|
||||
return off;
|
||||
}, [onExportProgress]);
|
||||
|
||||
// Query-Parameter einlesen
|
||||
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');
|
||||
const cameraParam = searchParams.get('camera');
|
||||
|
||||
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,
|
||||
to : to ? new Date(to) : null,
|
||||
});
|
||||
}
|
||||
if (directionParam) setDirectionFilter(directionParam);
|
||||
if (cameraParam !== null) setCameraFilter(cameraParam); // '' oder Name
|
||||
}, [searchParams]);
|
||||
|
||||
// URL bei Interaktion aktualisieren
|
||||
// URL aktuell halten
|
||||
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);
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (dateRange.from) params.set('timestampFrom', dateRange.from.toISOString());
|
||||
if (dateRange.to) params.set('timestampTo', dateRange.to.toISOString());
|
||||
if (directionFilter) params.set('direction', directionFilter);
|
||||
if (cameraFilter) params.set('camera', cameraFilter);
|
||||
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
}, [searchTerm, currentPage, dateRange, directionFilter, pathname, router]);
|
||||
}, [searchTerm, currentPage, dateRange, directionFilter, cameraFilter, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetNewMarkers) {
|
||||
@ -85,6 +219,7 @@ export default function RecognitionsTable({ resetNewMarkers, initialPage, initia
|
||||
if (dateRange.from) query.set('timestampFrom', dateRange.from.toISOString());
|
||||
if (dateRange.to) query.set('timestampTo', dateRange.to.toISOString());
|
||||
if (directionFilter) query.set('direction', directionFilter);
|
||||
if (cameraFilter) query.set('camera', cameraFilter);
|
||||
|
||||
fetch(`/api/recognitions?${query}`, { credentials: "include", method: "GET" })
|
||||
.then((res) => res.json())
|
||||
@ -92,9 +227,11 @@ export default function RecognitionsTable({ resetNewMarkers, initialPage, initia
|
||||
if (Array.isArray(json.data)) {
|
||||
setData(json.data);
|
||||
setTotalPages(json.totalPages || 1);
|
||||
setTotalMatching(json.totalCount || 0);
|
||||
} else {
|
||||
setData([]);
|
||||
setTotalPages(1);
|
||||
setTotalMatching(0);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@ -102,56 +239,308 @@ export default function RecognitionsTable({ resetNewMarkers, initialPage, initia
|
||||
setData([]);
|
||||
setTotalPages(1);
|
||||
});
|
||||
}, [currentPage, searchTerm, dateRange, directionFilter]);
|
||||
}, [currentPage, searchTerm, dateRange, directionFilter, cameraFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = (newEntry: Recognition) => {
|
||||
if (currentPage === 1) {
|
||||
setData((prev) => {
|
||||
const alreadyExists = prev.some(entry => entry.id === newEntry.id);
|
||||
if (alreadyExists) return prev;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
return [newEntry, ...prev].slice(0, itemsPerPage);
|
||||
});
|
||||
const off = onNewRecognition((newEntry: Recognition) => {
|
||||
if (currentPage !== 1) return;
|
||||
|
||||
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);
|
||||
setData(prev => {
|
||||
if (prev.some(entry => entry.id === newEntry.id)) return prev;
|
||||
return [newEntry, ...prev].slice(0, itemsPerPage);
|
||||
});
|
||||
|
||||
setNewestId(newEntry.id);
|
||||
setTimeout(() => {
|
||||
setNewestId((prevId) => (prevId === newEntry.id ? null : prevId));
|
||||
}, 2000);
|
||||
}
|
||||
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);
|
||||
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setNewestId(prevId => (prevId === newEntry.id ? null : prevId));
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
off();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
onNewRecognition(cb);
|
||||
}, [currentPage, onNewRecognition]);
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
// Hilfsfunktionen
|
||||
const isSelected = (id: number) =>
|
||||
allMatchingSelected ? !deselectedIds.has(id) : selectedIds.has(id);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
if (allMatchingSelected) {
|
||||
setDeselectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const allOnPageSelected =
|
||||
data.length > 0 && data.every(r => isSelected(r.id));
|
||||
const someOnPageSelected =
|
||||
data.some(r => isSelected(r.id)) && !allOnPageSelected;
|
||||
|
||||
const toggleAllOnPage = (checked: boolean) => {
|
||||
if (allMatchingSelected) {
|
||||
setDeselectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const row of data) {
|
||||
if (checked) next.delete(row.id); // Seite vollständig auswählen
|
||||
else next.add(row.id); // Seite vollständig abwählen
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const row of data) {
|
||||
if (checked) next.add(row.id);
|
||||
else next.delete(row.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (selectedCount === 0) {
|
||||
alert('Keine Einträge ausgewählt.');
|
||||
return;
|
||||
}
|
||||
|
||||
setExportError(null);
|
||||
|
||||
const filters = {
|
||||
search: searchTerm,
|
||||
direction: directionFilter,
|
||||
timestampFrom: dateRange.from?.toISOString() ?? null,
|
||||
timestampTo: dateRange.to?.toISOString() ?? null,
|
||||
camera: cameraFilter || '',
|
||||
};
|
||||
|
||||
const selection = allMatchingSelected
|
||||
? ({ mode: 'selected-all-except', exceptIds: Array.from(deselectedIds) } as const)
|
||||
: ({ mode: 'selected', ids: Array.from(selectedIds) } as const);
|
||||
|
||||
const jobId = (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
|
||||
? crypto.randomUUID()
|
||||
: `job_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
exportJobIdRef.current = jobId;
|
||||
|
||||
const filenameFromDisposition = (h?: string | null, fallback = `recognitions.${exportFormat}`) => {
|
||||
if (!h) return fallback;
|
||||
const m = /filename\*?=(?:UTF-8''|")?([^\";]+)/i.exec(h);
|
||||
if (!m) return fallback;
|
||||
try {
|
||||
return decodeURIComponent(m[1].replace(/"/g, ''));
|
||||
} catch {
|
||||
return m[1].replace(/"/g, '') || fallback;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
setExportRunning(true);
|
||||
setExportProgress(1);
|
||||
setExportStage('');
|
||||
setExportCounts(null);
|
||||
|
||||
const res = await fetch('/api/recognitions/export', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
format: exportFormat,
|
||||
filters,
|
||||
selection,
|
||||
fields: applyCoupling(exportFields),
|
||||
clientJobId: jobId,
|
||||
}),
|
||||
});
|
||||
|
||||
const blob = await res.blob();
|
||||
const disp = res.headers.get('Content-Disposition');
|
||||
const fileName = filenameFromDisposition(disp);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName || `recognitions.${exportFormat}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setExportProgress(100);
|
||||
setExportRunning(false);
|
||||
setExportStage('');
|
||||
setExportCounts(null);
|
||||
exportJobIdRef.current = null;
|
||||
|
||||
setExportOpen(false);
|
||||
} catch (err) {
|
||||
console.error('❌ Export:', err);
|
||||
const msg = String(err instanceof Error ? err.message : err);
|
||||
setExportError(msg);
|
||||
setExportRunning(false);
|
||||
setExportProgress(0);
|
||||
setExportStage('');
|
||||
setExportCounts(null);
|
||||
exportJobIdRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = allMatchingSelected
|
||||
? Math.max(0, (totalMatching || 0) - deselectedIds.size)
|
||||
: selectedIds.size;
|
||||
|
||||
useEffect(() => {
|
||||
if (masterRef.current) {
|
||||
masterRef.current.indeterminate = !allOnPageSelected && someOnPageSelected;
|
||||
}
|
||||
}, [allOnPageSelected, someOnPageSelected]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-4 xl:grid-cols-6">
|
||||
<div className="col-span-6">
|
||||
<div className="space-y-4">
|
||||
{/* Linke Spalte: Filter/Toolbar/Tabelle/Pagination */}
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<RecognitionsTableFilters
|
||||
searchTerm={searchTerm}
|
||||
directionFilter={directionFilter}
|
||||
selectedCamera={cameraFilter}
|
||||
setSearchTerm={setSearchTerm}
|
||||
setDirectionFilter={setDirectionFilter}
|
||||
setDateRange={setDateRange}
|
||||
setCurrentPage={setCurrentPage}
|
||||
setSelectedCamera={setCameraFilter}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-3 xl:col-span-4">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mt-2 mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{pageStart}–{pageEnd} von {totalMatching.toLocaleString('de-DE')} Einträgen
|
||||
</span>
|
||||
|
||||
{(!allMatchingSelected || selectedCount < totalMatching) ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAllMatchingSelected(true);
|
||||
setSelectedIds(new Set());
|
||||
setDeselectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
|
||||
<path d="M8.8 12.2l2.2 2.2 4.2-4.2" />
|
||||
</svg>
|
||||
Alle Einträge auswählen
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAllMatchingSelected(false);
|
||||
setSelectedIds(new Set());
|
||||
setDeselectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
Auswahl leeren
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(selectedCount > 0 || allMatchingSelected) && (
|
||||
<>
|
||||
<span className="ml-auto text-sm text-gray-500">
|
||||
{selectedCount.toLocaleString('de-DE')} {selectedCount === 1 ? 'Eintrag' : 'Einträge'} ausgewählt
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAllMatchingSelected(false);
|
||||
setSelectedIds(new Set());
|
||||
setDeselectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
Auswahl aufheben
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M8.25 11.25 12 15l3.75-3.75" />
|
||||
<path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Exportieren
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
<Table>
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
<Table.Cell header align="center">
|
||||
<Checkbox
|
||||
checked={allOnPageSelected}
|
||||
indeterminate={!allOnPageSelected && someOnPageSelected}
|
||||
onChange={(e) => toggleAllOnPage(e.currentTarget.checked)}
|
||||
label={<span className="sr-only">Alle auf dieser Seite auswählen</span>}
|
||||
containerClassName="justify-center"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>Kennzeichen</Table.Cell>
|
||||
<Table.Cell>Land</Table.Cell>
|
||||
<Table.Cell>Marke</Table.Cell>
|
||||
@ -167,14 +556,18 @@ export default function RecognitionsTable({ resetNewMarkers, initialPage, initia
|
||||
<RecognitionRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isSelected={selected?.id === entry.id}
|
||||
isSelected={selectedRow?.id === entry.id}
|
||||
isNew={newestId === entry.id}
|
||||
onClick={() => setSelected(entry)}
|
||||
onClick={() =>
|
||||
setSelectedRow(selectedRow?.id === entry.id ? null : entry)
|
||||
}
|
||||
checked={isSelected(entry.id)}
|
||||
onToggle={() => toggleRow(entry.id)}
|
||||
/>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-5 text-center text-gray-500 dark:text-neutral-400">
|
||||
<td colSpan={10} className="p-5 text-center text-gray-500 dark:text-neutral-400">
|
||||
Keine Daten gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
@ -184,18 +577,104 @@ export default function RecognitionsTable({ resetNewMarkers, initialPage, initia
|
||||
|
||||
<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)} />
|
||||
)}
|
||||
{/* Export-Modal */}
|
||||
<Modal
|
||||
open={exportOpen}
|
||||
onClose={() => {
|
||||
if (exportRunning) return;
|
||||
setExportOpen(false);
|
||||
setExportError(null);
|
||||
}}
|
||||
title="Ausgewählte Einträge exportieren"
|
||||
saveButton
|
||||
onSave={handleExport}
|
||||
maxWidth="max-w-xl"
|
||||
busy={exportRunning}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{exportError && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800"
|
||||
>
|
||||
<div className="font-medium mb-1">Export fehlgeschlagen</div>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs m-0">{exportError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-200 dark:border-neutral-700 p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h3 className="font-medium text-base text-gray-900 dark:text-neutral-100">
|
||||
Welche Informationen sollen exportiert werden?
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button size="small" variant="ghost" onClick={selectAllFields} disabled={exportRunning}>Alle</Button>
|
||||
<Button size="small" variant="ghost" onClick={selectDefault} disabled={exportRunning}>Standard</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{exportFields.length === 0 ? (
|
||||
<span className="text-xs text-gray-500 dark:text-neutral-400">Keine Felder ausgewählt</span>
|
||||
) : (
|
||||
exportFields.map(k => (
|
||||
<span
|
||||
key={k}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
|
||||
>
|
||||
{LABELS[k] ?? k}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{EXPORT_FIELDS
|
||||
.filter(f => !HIDDEN_FROM_GRID.includes(f.key))
|
||||
.map(f => (
|
||||
<Checkbox
|
||||
key={f.key}
|
||||
checked={exportFields.includes(f.key)}
|
||||
onChange={e => onToggleFieldCoupled(f.key, e.currentTarget.checked)}
|
||||
label={f.label}
|
||||
disabled={exportRunning}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 dark:border-neutral-700 p-4">
|
||||
<h3 className="mb-3 font-medium text-base text-gray-900 dark:text-neutral-100">
|
||||
Format wählen
|
||||
</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-2 max-w-none">
|
||||
<CheckboxGroup
|
||||
multiple={false}
|
||||
orientation="vertical"
|
||||
value={[exportFormat]}
|
||||
onChange={(vals) => setExportFormat((vals[0] as 'csv' | 'json' | 'pdf') ?? 'csv')}
|
||||
options={[
|
||||
{ value: 'csv', label: 'CSV', description: 'Trennzeichen: Semikolon (;)', icon: CsvImg },
|
||||
{ value: 'pdf', label: 'PDF',
|
||||
description: exportRunning && exportCounts
|
||||
? `${exportCounts.done}/${exportCounts.total} – ${exportStage || 'erzeuge…'}`
|
||||
: `${selectedCount} ${selectedCount===1?'Seite':'Seiten'}`,
|
||||
icon: PdfImg
|
||||
},
|
||||
{ value: 'json', label: 'JSON', icon: JsonImg },
|
||||
]}
|
||||
className="sm:col-span-3 max-w-full"
|
||||
itemClassName="w-full"
|
||||
progressByValue={exportRunning ? { [exportFormat]: exportProgress } : undefined}
|
||||
disabled={exportRunning}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// RecognitionsTableFilters.tsx
|
||||
'use client';
|
||||
|
||||
import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import DatePicker from './DatePicker';
|
||||
import { Button } from './Button';
|
||||
import Select from './Select';
|
||||
@ -9,11 +9,13 @@ import Select from './Select';
|
||||
type Props = {
|
||||
searchTerm: string;
|
||||
directionFilter: string;
|
||||
selectedCamera?: string | null;
|
||||
setSearchTerm: Dispatch<SetStateAction<string>>;
|
||||
setDirectionFilter: Dispatch<SetStateAction<string>>;
|
||||
setSelectedCamera: Dispatch<SetStateAction<string>>;
|
||||
setDateRange: Dispatch<SetStateAction<{ from: Date | null; to: Date | null }>>;
|
||||
setCurrentPage: Dispatch<SetStateAction<number>>;
|
||||
selectedCamera?: string | null;
|
||||
dateRange: { from: Date | null; to: Date | null };
|
||||
};
|
||||
|
||||
export default function RecognitionsTableFilters({
|
||||
@ -21,67 +23,101 @@ export default function RecognitionsTableFilters({
|
||||
directionFilter,
|
||||
setSearchTerm,
|
||||
setDirectionFilter,
|
||||
setSelectedCamera,
|
||||
setDateRange,
|
||||
setCurrentPage,
|
||||
selectedCamera
|
||||
selectedCamera,
|
||||
dateRange
|
||||
}: Props) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [hasFilter, setHasFilter] = useState(false);
|
||||
const resetDatePickerRef = useRef<() => void>(() => {});
|
||||
const [minDate, setMinDate] = useState<Date | undefined>();
|
||||
const [maxDate, setMaxDate] = useState<Date | undefined>();
|
||||
const [cameras, setCameras] = useState<string[]>([]);
|
||||
|
||||
// 📅 Zeitbereich laden
|
||||
|
||||
// 👉 String für den DatePicker bauen (YYYY-MM-DD bzw. "YYYY-MM-DD - YYYY-MM-DD")
|
||||
const dpValue = useMemo(() => {
|
||||
const toIso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const { from, to } = dateRange || {};
|
||||
if (from && to) return `${toIso(from)} - ${toIso(to)}`;
|
||||
if (from) return toIso(from);
|
||||
if (to) return toIso(to);
|
||||
return '';
|
||||
}, [dateRange]);
|
||||
|
||||
// Kameraliste laden
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/cameras', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const { cameras } = await res.json();
|
||||
if (abort) return;
|
||||
setCameras(Array.isArray(cameras) ? cameras : []);
|
||||
} catch {}
|
||||
})();
|
||||
return () => { abort = true; };
|
||||
}, []);
|
||||
|
||||
// Date-Bounds laden (du hattest das schon – nutzt selectedCamera)
|
||||
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;
|
||||
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
|
||||
}, [selectedCamera]);
|
||||
|
||||
// 👉 wenn per URL ein Zeitraum kommt, direkt anzeigen und den Zurücksetzen-Button aktivieren
|
||||
useEffect(() => {
|
||||
setHasFilter(!!dateRange?.from || !!dateRange?.to);
|
||||
}, [dateRange]);
|
||||
|
||||
// 📆 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
|
||||
resetDatePickerRef.current?.();
|
||||
setDateRange({ from: null, to: null });
|
||||
setHasFilter(false);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 📥 Auswahländerung bei Richtung
|
||||
const handleSelectionChange = (selected: string[]) => {
|
||||
const handleDirectionChange = (selected: string[]) => {
|
||||
const value = selected[0] ?? '';
|
||||
setDirectionFilter(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleCameraChange = (selected: string[]) => { // 👈 neu
|
||||
const value = selected[0] ?? '';
|
||||
setSelectedCamera(value); // '' = alle Kameras
|
||||
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
|
||||
ref={searchInputRef}
|
||||
id="searchInput"
|
||||
type="text"
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
@ -90,12 +126,40 @@ export default function RecognitionsTableFilters({
|
||||
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..."
|
||||
/>
|
||||
|
||||
{/* linkes Icon */}
|
||||
<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>
|
||||
|
||||
{/* rechter Clear-Button – nur anzeigen, wenn es Text gibt */}
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Suche löschen"
|
||||
title="Suche löschen"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}}
|
||||
className="absolute inset-y-0 end-0 flex items-center pe-2
|
||||
text-gray-400 hover:text-gray-600 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900
|
||||
rounded-full cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center justify-center h-8 w-8 -mr-1">
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 📅 DatePicker */}
|
||||
@ -104,6 +168,8 @@ export default function RecognitionsTableFilters({
|
||||
id='result-filter'
|
||||
title='Zeitraum auswählen'
|
||||
selectionDatesMode='multiple-ranged'
|
||||
value={dpValue}
|
||||
suppressInitialChange
|
||||
onDateChange={handleDateRangeChange}
|
||||
onReset={(fn) => { resetDatePickerRef.current = fn; }}
|
||||
disableUnavailableDates={true}
|
||||
@ -138,10 +204,27 @@ export default function RecognitionsTableFilters({
|
||||
]}
|
||||
selected={directionFilter}
|
||||
multiple={false}
|
||||
placeholder="Richtung auswählen..."
|
||||
onChange={handleSelectionChange}
|
||||
placeholder="Richtung auswählen…"
|
||||
onChange={handleDirectionChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kamera – nur anzeigen, wenn mehrere verfügbar sind */}
|
||||
{cameras.length > 1 && (
|
||||
<div className="w-full md:w-auto flex-shrink-0">
|
||||
<Select
|
||||
id="camera-filter"
|
||||
options={[
|
||||
{ label: 'Alle Kameras', value: '' },
|
||||
...cameras.map(c => ({ label: c, value: c })),
|
||||
]}
|
||||
selected={selectedCamera ?? ''}
|
||||
multiple={false}
|
||||
placeholder="Kamera auswählen…"
|
||||
onChange={handleCameraChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,23 @@
|
||||
// app/components/SSEContext.tsx ← vormals SSEContext.tsx
|
||||
// app/components/SSEContext.tsx
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useCurrentUser } from './AuthContext'; // ◄ NEU
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useCurrentUser } from './AuthContext';
|
||||
import { Recognition } from '@/types/plates';
|
||||
|
||||
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
type SSEContextType = {
|
||||
onNewRecognition: (cb: (r: Recognition) => void) => void;
|
||||
|
||||
type ExportProgressMsg = {
|
||||
jobId: string;
|
||||
stage?: string;
|
||||
done?: number;
|
||||
total?: number;
|
||||
progress?: number; // 0..100 (serverseitig typ. bis 99)
|
||||
};
|
||||
|
||||
type SSEContextType = {
|
||||
onNewRecognition: (cb: (r: Recognition) => void) => () => void; // ← gibt Unsubscribe zurück
|
||||
onExportProgress: (cb: (m: ExportProgressMsg) => void) => () => void; // ← neu
|
||||
connectionStatus: ConnectionStatus;
|
||||
newCount: number;
|
||||
resetNewCount: () => void;
|
||||
@ -16,57 +26,89 @@ type SSEContextType = {
|
||||
const SSEContext = createContext<SSEContextType | undefined>(undefined);
|
||||
|
||||
export function SSEProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useCurrentUser(); // ◄ NEU
|
||||
const listeners = useRef<((r: Recognition) => void)[]>([]);
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Listener-Sets statt Arrays → leichtes Unsubscribe & keine Duplikate
|
||||
const recListeners = useRef<Set<(r: Recognition) => void>>(new Set());
|
||||
const exportListeners= useRef<Set<(m: ExportProgressMsg) => void>>(new Set());
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
|
||||
/* ───────── EventSource an/abmelden ──────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (!user) { // noch nicht eingeloggt → keine Verbindung
|
||||
if (!user) {
|
||||
setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---------- Verbindung aufbauen -------------------------------- */
|
||||
setStatus('connecting');
|
||||
|
||||
const es = new EventSource(
|
||||
`/api/recognitions/stream`,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
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('new-recognition', (e) => {
|
||||
try {
|
||||
const rec = JSON.parse((e as MessageEvent).data) as Recognition;
|
||||
recListeners.current.forEach((cb) => {
|
||||
try { cb(rec); } catch {}
|
||||
});
|
||||
setNewCount((c) => c + 1);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
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');
|
||||
// Optional: ping nur, damit Verbindung „lebt“
|
||||
es.addEventListener('ping', () => { /* noop */ });
|
||||
|
||||
// NEU: Export-Fortschritt
|
||||
es.addEventListener('export-progress', (e) => {
|
||||
try {
|
||||
const msg = JSON.parse((e as MessageEvent).data) as ExportProgressMsg;
|
||||
if (!msg || !msg.jobId) return;
|
||||
exportListeners.current.forEach((cb) => {
|
||||
try { cb(msg); } catch {}
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
es.onerror = err => {
|
||||
// Server-initiierter Logout
|
||||
es.addEventListener('logout', (e) => {
|
||||
try {
|
||||
const { reason } = JSON.parse((e as MessageEvent).data);
|
||||
console.info('Server verlangt Logout:', reason);
|
||||
} catch {}
|
||||
fetch('/api/logout', { method: 'POST', credentials: 'include' })
|
||||
.finally(() => (window.location.href = '/login'));
|
||||
});
|
||||
|
||||
es.onerror = (err) => {
|
||||
console.warn('SSE-Fehler:', err);
|
||||
// EventSource versucht selbst zu reconnecten; wir zeigen "error"/"connecting".
|
||||
setStatus('error');
|
||||
// kurze Zeit später wieder als "connecting" markieren
|
||||
setTimeout(() => setStatus('connecting'), 1000);
|
||||
};
|
||||
|
||||
/* ---------- Aufräumen (Logout / Tab-Wechsel / Hot-Reload) ------ */
|
||||
return () => {
|
||||
es.close();
|
||||
setStatus('disconnected');
|
||||
};
|
||||
}, [user, user?.id]); // ◄ Effekt läuft nur, wenn sich der Benutzer ändert
|
||||
}, [user]); // neu verbinden, wenn sich der eingeloggte User ändert
|
||||
|
||||
// Registrieren & Unsubscribe zurückgeben
|
||||
const onNewRecognition = useCallback((cb: (r: Recognition) => void) => {
|
||||
recListeners.current.add(cb);
|
||||
return () => recListeners.current.delete(cb);
|
||||
}, []);
|
||||
|
||||
const onExportProgress = useCallback((cb: (m: ExportProgressMsg) => void) => {
|
||||
exportListeners.current.add(cb);
|
||||
return () => exportListeners.current.delete(cb);
|
||||
}, []);
|
||||
|
||||
/* ───────── Context-Objekt ───────────────────────────────────────── */
|
||||
const ctx: SSEContextType = {
|
||||
onNewRecognition: cb => listeners.current.push(cb),
|
||||
onNewRecognition,
|
||||
onExportProgress, // ← im Frontend verwenden
|
||||
connectionStatus: status,
|
||||
newCount,
|
||||
resetNewCount: () => setNewCount(0),
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/components/Table.tsx
|
||||
|
||||
import { ReactNode, HTMLAttributes } from 'react';
|
||||
|
||||
export function Table({ children }: { children: ReactNode }) {
|
||||
@ -17,11 +19,11 @@ export function Table({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function Head({ children }: { children: ReactNode }) {
|
||||
return <thead className="bg-gray-50 dark:bg-neutral-700">{children}</thead>;
|
||||
return <thead className="bg-white 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>;
|
||||
return <tbody className="bg-neutral-50 dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">{children}</tbody>;
|
||||
}
|
||||
|
||||
function Row({
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
// /src/app/components/Tabs.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
type TabKey = 'dashboard' | 'results' | 'notifications' | 'admin';
|
||||
type TabKey = 'dashboard' | 'results' | 'notifications' | 'downloads' | 'admin';
|
||||
|
||||
type TabsProps = {
|
||||
newCount?: number;
|
||||
isAdmin: boolean;
|
||||
canDownload?: boolean;
|
||||
};
|
||||
|
||||
export default function Tabs({ newCount = 0, isAdmin = false }: TabsProps) {
|
||||
export default function Tabs({ newCount = 0, isAdmin = false, canDownload = false }: TabsProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const activePath = pathname === '' ? '/' : pathname;
|
||||
@ -51,19 +54,41 @@ export default function Tabs({ newCount = 0, isAdmin = false }: TabsProps) {
|
||||
</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>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
...(canDownload ? [{
|
||||
key: 'downloads' as const,
|
||||
label: 'Downloads',
|
||||
href: '/downloads',
|
||||
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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Pfeil runter */}
|
||||
<path d="M12 3v12" />
|
||||
<path d="M8.25 11.25 12 15l3.75-3.75" />
|
||||
{/* Ablage */}
|
||||
<path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</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 (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/components/ThemeProvider.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, createContext, useContext } from 'react';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// UserForm.tsx
|
||||
// components/UserForm.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
@ -9,6 +9,9 @@ import Alert from './Alert';
|
||||
import { CameraAccessEntry } from '@/types/user';
|
||||
import CameraList from './CameraList';
|
||||
import DatePicker from './DatePicker';
|
||||
import Checkbox from './Checkbox';
|
||||
import FormSection from './FormSection';
|
||||
import { Field } from './Field';
|
||||
|
||||
export default function UserForm({ onUserCreated }: { onUserCreated: () => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
@ -16,82 +19,56 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
|
||||
const [selectedCameras, setSelectedCameras] = useState<CameraAccessEntry[]>([]);
|
||||
const [cameraOptions, setCameraOptions] = useState<string[]>([]);
|
||||
const [cameraDateRanges, setCameraDateRanges] = useState<Record<string, { startDate: string; endDate: string }>>({});
|
||||
const [allowDownloads, setAllowDownloads] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Modal
|
||||
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), []);
|
||||
|
||||
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 };
|
||||
});
|
||||
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 }];
|
||||
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
|
||||
)
|
||||
);
|
||||
setSelectedCameras(prev => prev.map(e => (e.camera === camera ? { ...e, [field]: value } : e)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/* ────────────────────────────────────────────
|
||||
Benutzer anlegen
|
||||
──────────────────────────────────────────── */
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const cameraAccess = selectedCameras.map(({ camera, from, to }) => ({
|
||||
camera,
|
||||
from: from ? from.toISOString() : null,
|
||||
to: to ? to.toISOString() : null
|
||||
to: to ? to.toISOString() : null,
|
||||
}));
|
||||
|
||||
const res = await fetch(`/api/admin/create-user`, {
|
||||
@ -101,8 +78,9 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString().split('T')[0] : null,
|
||||
cameraAccess
|
||||
})
|
||||
cameraAccess,
|
||||
features: allowDownloads ? ['DOWNLOADS'] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@ -113,65 +91,65 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
|
||||
onUserCreated();
|
||||
}
|
||||
|
||||
// Formular zurücksetzen
|
||||
setUsername('');
|
||||
setExpiresAt(null);
|
||||
setSelectedCameras([]);
|
||||
};
|
||||
setAllowDownloads(false);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────
|
||||
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
|
||||
/>
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-full">
|
||||
{/* Two columns from md up */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 auto-rows-fr">
|
||||
{/* Left: Basisdaten (5/12) */}
|
||||
<FormSection title="Basisdaten" className="md:col-span-5 h-full">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
|
||||
<Field label="Benutzername" htmlFor="input-username" className="sm:col-span-4">
|
||||
<input
|
||||
id="input-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full h-11 px-3 border-gray-200 rounded-lg text-base
|
||||
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-200"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ablaufdatum" className="sm:col-span-4">
|
||||
<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-2"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Optionen" className="sm:col-span-4">
|
||||
<Checkbox
|
||||
id="allow-downloads"
|
||||
checked={allowDownloads}
|
||||
onChange={(e) => setAllowDownloads(e.target.checked)}
|
||||
label={<span className="text-sm text-gray-700 dark:text-neutral-300 leading-6">
|
||||
Zugriff auf Downloads erlauben
|
||||
</span>}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* Right: Kameras (5/12) */}
|
||||
<FormSection
|
||||
title="Zugriff auf Kameras"
|
||||
description="Wähle Kameras und optional Zeiträume."
|
||||
className="md:col-span-5 h-full"
|
||||
bodyClassName="flex flex-col"
|
||||
>
|
||||
<CameraList
|
||||
idPrefix="form"
|
||||
cameraOptions={cameraOptions}
|
||||
@ -182,17 +160,16 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
|
||||
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>
|
||||
</FormSection>
|
||||
|
||||
{/* Right: Actions (2/12) */}
|
||||
<div className="flex flex-col md:col-span-2 h-full justify-end">
|
||||
<Button type="submit" variant="solid" color="blue" size="small">
|
||||
Benutzer anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Passwort-Modal */}
|
||||
<Modal
|
||||
|
||||
@ -26,7 +26,7 @@ function formatTimeLeft(seconds: number): string {
|
||||
}
|
||||
|
||||
// Texte zentral halten, falls mehrfach benötigt
|
||||
const LOGOUT_REASON_MANUAL = 'Du hast dich abgemeldet.';
|
||||
const LOGOUT_REASON_MANUAL = 'Du hast dich erfolgreich 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.';
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { useCurrentUser } from "./AuthContext";
|
||||
import DatePicker from "./DatePicker";
|
||||
import CameraList from "./CameraList";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import Checkbox from "./Checkbox";
|
||||
|
||||
export default function UserTable() {
|
||||
const { user: current } = useCurrentUser();
|
||||
@ -29,13 +30,13 @@ export default function UserTable() {
|
||||
const [selectedCameras, setSelectedCameras] = useState<CameraAccessEntry[]>([]);
|
||||
const [cameraDateRanges, setCameraDateRanges] = useState<Record<string, { startDate: string; endDate: string }>>({});
|
||||
|
||||
// ⬇️ NEU: Edit-Checkbox-Status
|
||||
const [editAllowDownloads, setEditAllowDownloads] = useState(false);
|
||||
|
||||
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',
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
@ -51,41 +52,30 @@ export default function UserTable() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
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',
|
||||
});
|
||||
const handleBlockAccess = async (userId: string) => {
|
||||
await fetch(`/api/admin/block-user/${userId}`, { method: 'POST', credentials: 'include' });
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: number) => {
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (!confirm('Soll dieser Benutzer wirklich gelöscht werden?')) return;
|
||||
|
||||
await fetch(`/api/admin/delete-user/${userId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
await fetch(`/api/admin/delete-user/${userId}`, { method: 'DELETE', credentials: 'include' });
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
@ -93,11 +83,8 @@ export default function UserTable() {
|
||||
setEditUserOriginalName(user.username);
|
||||
|
||||
let recognitionRanges: { camera: string; startDate: string; endDate: string }[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/recognitions/dates`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const res = await fetch(`/api/recognitions/dates`, { credentials: 'include' });
|
||||
const raw = await res.json();
|
||||
recognitionRanges = Array.isArray(raw) ? raw : [];
|
||||
} catch (err) {
|
||||
@ -125,6 +112,10 @@ export default function UserTable() {
|
||||
...user,
|
||||
expiresAt: user.expiresAt?.slice(0, 10) ?? ''
|
||||
});
|
||||
|
||||
// ⬇️ NEU: Checkbox mit aktuellem Featurezustand vorbelegen
|
||||
setEditAllowDownloads(!!user.features?.includes('DOWNLOADS'));
|
||||
|
||||
setEditUserModalOpen(true);
|
||||
};
|
||||
|
||||
@ -142,7 +133,9 @@ export default function UserTable() {
|
||||
camera,
|
||||
from,
|
||||
to,
|
||||
}))
|
||||
})),
|
||||
// ⬇️ NEU: Features mitsenden
|
||||
features: editAllowDownloads ? ['DOWNLOADS'] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
@ -162,15 +155,9 @@ export default function UserTable() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateChange = (
|
||||
camera: string,
|
||||
field: 'from' | 'to',
|
||||
value: Date | null
|
||||
) => {
|
||||
const handleDateChange = (camera: string, field: 'from' | 'to', value: Date | null) => {
|
||||
setSelectedCameras(prev =>
|
||||
prev.map(e =>
|
||||
e.camera === camera ? { ...e, [field]: value } : e
|
||||
)
|
||||
prev.map(e => (e.camera === camera ? { ...e, [field]: value } : e))
|
||||
);
|
||||
};
|
||||
|
||||
@ -182,6 +169,7 @@ export default function UserTable() {
|
||||
<Table.Cell>Benutzername</Table.Cell>
|
||||
<Table.Cell>Ablaufdatum</Table.Cell>
|
||||
<Table.Cell>Zugriff auf Kameras</Table.Cell>
|
||||
<Table.Cell>Features</Table.Cell>
|
||||
<Table.Cell>Rolle</Table.Cell>
|
||||
<Table.Cell>Letzte Anmeldung</Table.Cell>
|
||||
<Table.Cell>Aktionen</Table.Cell>
|
||||
@ -234,6 +222,13 @@ export default function UserTable() {
|
||||
);
|
||||
})}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(user.features?.includes('DOWNLOADS')) && (
|
||||
<div className="mt-1 inline-flex items-center rounded bg-emerald-100 text-emerald-800 px-2 py-0.5 text-xs">
|
||||
Downloads
|
||||
</div>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.isAdmin ? 'Administrator' : 'Benutzer'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.lastLogin ? formatDateTime(user.lastLogin) : 'nie'}
|
||||
@ -256,6 +251,7 @@ export default function UserTable() {
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
{/* Neues Passwort Modal */}
|
||||
<Modal
|
||||
open={showPasswordModal}
|
||||
onClose={() => {
|
||||
@ -278,6 +274,7 @@ export default function UserTable() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit-Modal mit Downloads-Checkbox */}
|
||||
<Modal
|
||||
open={editUserModalOpen}
|
||||
onClose={() => setEditUserModalOpen(false)}
|
||||
@ -296,13 +293,7 @@ export default function UserTable() {
|
||||
type="text"
|
||||
value={editUserData.username}
|
||||
onChange={(e) =>
|
||||
setEditUserData((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
username: e.target.value,
|
||||
};
|
||||
})
|
||||
setEditUserData((prev) => prev ? { ...prev, username: e.target.value } : prev)
|
||||
}
|
||||
className="w-full border rounded-lg px-3 py-2.5 dark:bg-neutral-900 dark:border-neutral-700 dark:text-white"
|
||||
/>
|
||||
@ -316,15 +307,12 @@ export default function UserTable() {
|
||||
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,
|
||||
};
|
||||
})
|
||||
setEditUserData((prev) => prev ? {
|
||||
...prev,
|
||||
expiresAt: from
|
||||
? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
|
||||
: null,
|
||||
} : prev)
|
||||
}
|
||||
disablePastDates={true}
|
||||
minDate={new Date()}
|
||||
@ -345,6 +333,19 @@ export default function UserTable() {
|
||||
handleDateChange={handleDateChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ⬇️ NEU: Downloads-Feature umschalten */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-2 dark:text-white">Optionen</label>
|
||||
<Checkbox
|
||||
id="edit-allow-downloads"
|
||||
checked={editAllowDownloads}
|
||||
onChange={(e) => setEditAllowDownloads(e.target.checked)}
|
||||
label={<span className="text-sm text-gray-700 dark:text-neutral-300 leading-6">
|
||||
Zugriff auf Downloads erlauben
|
||||
</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
// /src/app/components/charts/ChartBar.tsx
|
||||
'use client';
|
||||
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Title,
|
||||
TooltipItem
|
||||
} from 'chart.js';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import type { ChartOptions, TooltipItem, ChartData } from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import { useMemo } from 'react';
|
||||
import useIsDark from '@/app/hooks/useIsDark';
|
||||
|
||||
Chart.register(ChartDataLabels);
|
||||
|
||||
export type DayCount = { date: string; count: number };
|
||||
|
||||
@ -23,97 +18,88 @@ export type PlateCount = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ChartBarProps = {
|
||||
data: DayCount[];
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, ChartDataLabels, Tooltip, Legend, Title);
|
||||
type ChartBarProps = { data: DayCount[]; horizontal?: boolean };
|
||||
|
||||
export default function ChartBar({ data, horizontal = false }: ChartBarProps) {
|
||||
const isDark = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart<'bar'> | null>(null);
|
||||
|
||||
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 isDark = useIsDark();
|
||||
|
||||
const options = useMemo(() => ({
|
||||
indexAxis: (horizontal ? 'y' : 'x') as 'x' | 'y',
|
||||
const labelFont = 12;
|
||||
const labelStroke = isDark ? 0 : 3;
|
||||
const labelOffset = horizontal ? 4 : 6;
|
||||
|
||||
// genug Luft über der ChartArea für die Labels
|
||||
const topPadding = horizontal ? 8 : (labelFont + labelStroke + labelOffset + 8);
|
||||
// etwas Luft rechts, falls horizontal + Labels rechts ausgerichtet
|
||||
const rightPadding = horizontal ? (labelFont + labelStroke + 12) : 8;
|
||||
|
||||
const chartData = useMemo<ChartData<'bar', number[], string>>(
|
||||
() => ({
|
||||
labels: data.map((d) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar',
|
||||
label: 'Erkennungen',
|
||||
data: data.map((d) => d.count),
|
||||
backgroundColor: isDark ? '#3b82f6' : '#2563eb',
|
||||
borderRadius: 4,
|
||||
datalabels: {
|
||||
color: isDark ? '#f3f4f6' : '#111827',
|
||||
anchor: horizontal ? 'end' : 'end',
|
||||
align: horizontal ? 'right' : 'top',
|
||||
offset: horizontal ? 4 : 6,
|
||||
textStrokeColor: isDark ? 'transparent' : 'rgba(255,255,255,0.9)',
|
||||
textStrokeWidth: isDark ? 0 : 3,
|
||||
font: { weight: 'bold', size: 12 },
|
||||
display: (ctx) => Number(ctx.dataset.data[ctx.dataIndex] ?? 0) >= 1,
|
||||
formatter: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
clip: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data, isDark, horizontal]
|
||||
);
|
||||
|
||||
const options = useMemo<ChartOptions<'bar'>>(() => ({
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
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}`;
|
||||
label: (ctx: TooltipItem<'bar'>) => {
|
||||
const v = ctx.raw as number;
|
||||
return `Erkennungen: ${v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
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),
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
padding: { top: topPadding, right: rightPadding, bottom: 4, left: 4 }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: isDark ? '#a3a3a3' : '#374151',
|
||||
font: { size: 13 }
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? '#404040' : '#e5e7eb'
|
||||
}
|
||||
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'
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}), [horizontal, isDark, topPadding, rightPadding]);
|
||||
|
||||
return (
|
||||
<Bar data={chartData} options={options} />
|
||||
);
|
||||
useEffect(() => {
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new Chart(ctx, { type: 'bar', data: chartData, options });
|
||||
return () => chartRef.current?.destroy();
|
||||
}, [chartData, options]);
|
||||
|
||||
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// /src/app/components/charts/ChartContainer.tsx
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
@ -5,23 +6,50 @@ import Card from '../Card';
|
||||
|
||||
type ChartContainerProps = {
|
||||
title?: string;
|
||||
/** Legacy: fixe Höhe (px, rem, …). Wenn gesetzt, überschreibt aspect/fill. */
|
||||
height?: number | string;
|
||||
/** Breite/Höhe – z.B. 2 = Höhe = Breite / 2 (responsive) */
|
||||
aspect?: number;
|
||||
/** Neu: nimmt die volle Höhe des Elterncontainers ein */
|
||||
fill?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
/** Mindesthöhe, falls Spalte sehr schmal wird */
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
export default function ChartContainer({
|
||||
title,
|
||||
height = 'auto',
|
||||
height,
|
||||
aspect,
|
||||
fill = false,
|
||||
className = '',
|
||||
children
|
||||
children,
|
||||
minHeight = 180,
|
||||
}: ChartContainerProps) {
|
||||
const useAspect = !fill && height == null && aspect != null && aspect > 0;
|
||||
|
||||
return (
|
||||
<Card className={`w-full ${className}`}>
|
||||
<Card className={`w-full h-full ${className}`}>
|
||||
{title && <h2 className="text-xl font-semibold mb-2">{title}</h2>}
|
||||
<div style={{ height }} className="relative w-full">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{fill ? (
|
||||
// Füllt die komplette zur Verfügung stehende Höhe
|
||||
<div className="relative w-full h-full" style={{ minHeight }}>
|
||||
{children}
|
||||
</div>
|
||||
) : useAspect ? (
|
||||
// Responsive Aspect-Box
|
||||
<div className="relative w-full" style={{ minHeight }}>
|
||||
<div style={{ paddingTop: `${100 / aspect}%` }} />
|
||||
<div className="absolute inset-0">{children}</div>
|
||||
</div>
|
||||
) : (
|
||||
// Fixe Höhe (Bestand)
|
||||
<div className="relative w-full" style={{ height: height ?? 'auto', minHeight }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
// /src/app/components/charts/ChartPie.tsx
|
||||
|
||||
'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 { useEffect, useMemo, useRef } from 'react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import type { ChartOptions, ChartData, ChartDataset } from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import type { DayCount } from './ChartBar';
|
||||
import useIsDark from '@/app/hooks/useIsDark';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels);
|
||||
Chart.register(ChartDataLabels);
|
||||
|
||||
type ChartPieProps = {
|
||||
data: DayCount[];
|
||||
@ -21,83 +17,80 @@ type ChartPieProps = {
|
||||
};
|
||||
|
||||
export default function ChartPie({ data, legend = true }: ChartPieProps) {
|
||||
const isDark =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart<'pie'> | null>(null);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const isDark = useIsDark();
|
||||
|
||||
const chartData = useMemo<ChartData<'pie', number[], string>>(() => {
|
||||
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
|
||||
}
|
||||
]
|
||||
datasets: [{
|
||||
type: 'pie', // <- sauber getypt (vermeidet TS-Fehler)
|
||||
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(() => ({
|
||||
const options = useMemo<ChartOptions<'pie'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: legend,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: isDark ? '#a3a3a3' : '#4b5563'
|
||||
}
|
||||
labels: { color: isDark ? '#a3a3a3' : '#4b5563' }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.raw;
|
||||
const label = context.label;
|
||||
return `${label}: ${value} Erkennungen`;
|
||||
}
|
||||
label: (ctx) => `${ctx.label ?? ''}: ${ctx.raw as number} Erkennungen`
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
color: isDark ? '#d1d5db' : '#374151',
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
size: 12
|
||||
},
|
||||
color: isDark ? '#f3f4f6' : '#111827',
|
||||
font: { weight: 'normal', size: 12 },
|
||||
anchor: 'end',
|
||||
align: 'start',
|
||||
offset: 6,
|
||||
clamp: true,
|
||||
clip: false,
|
||||
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;
|
||||
const ds = ctx.chart.data.datasets[0] as ChartDataset<'pie', number[]>;
|
||||
const total = (ds.data as number[]).reduce((s, v) => s + v, 0);
|
||||
const value = (ds.data as number[])[ctx.dataIndex] ?? 0;
|
||||
return total > 0 ? value / total >= 0.03 : false; // ab 3% anzeigen
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
padding: 20
|
||||
},
|
||||
clip: false,
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 800,
|
||||
easing: 'easeOutCubic'
|
||||
}
|
||||
},
|
||||
layout: { padding: 20 }
|
||||
}), [isDark, legend]);
|
||||
|
||||
return (
|
||||
<Pie data={chartData} options={options} />
|
||||
);
|
||||
useEffect(() => {
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new Chart(ctx, { type: 'pie', data: chartData, options });
|
||||
|
||||
return () => chartRef.current?.destroy();
|
||||
}, [chartData, options]);
|
||||
|
||||
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
@ -123,8 +123,6 @@ body {
|
||||
color: var(--vc-text-light) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.vc-date {
|
||||
width: 42px !important;
|
||||
}
|
||||
56
frontend/src/app/hooks/useIsDark.ts
Normal file
56
frontend/src/app/hooks/useIsDark.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// /src/app/hooks/useIsDark.ts
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useIsDark() {
|
||||
const getNow = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const root = document.documentElement;
|
||||
if (root.classList.contains('dark')) return true;
|
||||
if (root.classList.contains('light')) return false;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('hs_theme');
|
||||
if (saved === 'dark') return true;
|
||||
if (saved === 'light') return false;
|
||||
}
|
||||
if (typeof window !== 'undefined' && 'matchMedia' in window) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const [isDark, setIsDark] = useState<boolean>(getNow);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
const update = () => setIsDark(getNow());
|
||||
|
||||
// 1) Änderungen an der <html class> beobachten
|
||||
const mo = new MutationObserver(update);
|
||||
mo.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// 2) Änderungen von hs_theme (anderer Tab etc.)
|
||||
const onStorage = (e: StorageEvent) => { if (e.key === 'hs_theme') update(); };
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
// 3) System-Theme
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener?.('change', update);
|
||||
|
||||
// Initial sync (falls sich beim Mount was ändert)
|
||||
update();
|
||||
|
||||
return () => {
|
||||
mo.disconnect();
|
||||
window.removeEventListener('storage', onStorage);
|
||||
mq.removeEventListener?.('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/unsubscribe/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
@ -30,8 +32,8 @@ export default function UnsubscribePage() {
|
||||
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;';
|
||||
document.cookie = 'unsubscribeStatus=; Path=/; Max-Age=0; SameSite=None; Secure';
|
||||
document.cookie = 'unsubscribeRule=; Path=/; Max-Age=0; SameSite=None; Secure';
|
||||
|
||||
setLoading(false); // <--- Ladezustand beenden
|
||||
}, 100);
|
||||
|
||||
@ -1,35 +1,38 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { fetch, Agent } from 'undici'; // ⬅️ Agent von undici!
|
||||
// /src/lib/auth.ts
|
||||
import { cookies } from 'next/headers'
|
||||
import { fetch, Agent } from 'undici'
|
||||
|
||||
const BASE_URL = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const BASE_URL = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '')
|
||||
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false, // self-signed Zertifikate akzeptieren
|
||||
},
|
||||
});
|
||||
connect: { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
tokenExpiresAt?: number;
|
||||
};
|
||||
type ApiMeResponse = {
|
||||
id: string
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
tokenExpiresAt?: number
|
||||
features?: ('DOWNLOADS')[]
|
||||
}
|
||||
|
||||
export type User = ApiMeResponse & {
|
||||
canDownload: boolean
|
||||
}
|
||||
|
||||
export async function getServerUser(): Promise<User | null> {
|
||||
const cookieStore = await cookies(); // synchron
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
if (!cookieHeader) return null;
|
||||
const cookieStore = await cookies()
|
||||
const cookieHeader = cookieStore.toString()
|
||||
if (!cookieHeader) return null
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/me`, {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
dispatcher: agent, // ✔️ funktioniert jetzt
|
||||
});
|
||||
headers: { cookie: cookieHeader },
|
||||
dispatcher: agent,
|
||||
})
|
||||
if (!res.ok) return null
|
||||
|
||||
if (!res.ok) return null;
|
||||
const payload = (await res.json()) as ApiMeResponse
|
||||
const canDownload = !!payload.features?.includes('DOWNLOADS')
|
||||
|
||||
return res.json() as Promise<User>;
|
||||
return { ...payload, canDownload }
|
||||
}
|
||||
|
||||
@ -104,14 +104,14 @@ function defaultMessage(reason: LogoutReason | undefined): string {
|
||||
case 'timeout':
|
||||
return 'Du wurdest wegen Inaktivität abgemeldet.';
|
||||
case 'expired':
|
||||
return 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.';
|
||||
return 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.';
|
||||
case 'server':
|
||||
return 'Du wurdest vom Server abgemeldet.';
|
||||
case 'error':
|
||||
return 'Du wurdest abgemeldet (technischer Fehler).';
|
||||
case 'manual':
|
||||
default:
|
||||
return 'Du hast dich abgemeldet.';
|
||||
return 'Du hast dich erfolgreich abgemeldet.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
import { JwtPayload } from 'jsonwebtoken';
|
||||
// /src/middleware.ts
|
||||
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 {
|
||||
function decodeJwtPayload(token: string) {
|
||||
try {
|
||||
const base64Payload = token.split('.')[1];
|
||||
const payload = atob(base64Payload);
|
||||
return JSON.parse(payload);
|
||||
const part = token.split('.')[1] || '';
|
||||
const base64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '=');
|
||||
return JSON.parse(atob(base64));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -22,7 +18,7 @@ export function middleware(req: NextRequest) {
|
||||
const token = req.cookies.get('token')?.value;
|
||||
const payload = token ? decodeJwtPayload(token) : null;
|
||||
|
||||
// 🔓 Ignoriere API, static, _next etc.
|
||||
// Ignoriere API & statics
|
||||
if (
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
@ -32,26 +28,28 @@ export function middleware(req: NextRequest) {
|
||||
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();
|
||||
}
|
||||
// Login-Seite: wenn eingeloggt → weiter
|
||||
if (pathname === '/login') return NextResponse.next();
|
||||
|
||||
// 🔐 Kein Token → redirect zu Login
|
||||
// Kein Token → Login
|
||||
if (!token || !payload) {
|
||||
return NextResponse.redirect(new URL('/login', req.url));
|
||||
}
|
||||
|
||||
// 🔒 Admin-Check
|
||||
if (pathname.startsWith('/admin') && !payload.isAdmin) {
|
||||
const isAdmin = !!payload.isAdmin;
|
||||
const canDownloadClaim = !!payload.canDownload; // nur "Best-Effort"
|
||||
|
||||
// Admin-Bereich hart sperren, wenn Claim sagt: kein Admin
|
||||
if (pathname.startsWith('/admin') && !isAdmin) {
|
||||
return NextResponse.redirect(new URL('/', req.url));
|
||||
}
|
||||
|
||||
// Downloads weich sperren (Claim reicht für schnellen Redirect),
|
||||
// die finale Prüfung erfolgt in der Seite selbst
|
||||
if (pathname.startsWith('/downloads') && !(isAdmin || canDownloadClaim)) {
|
||||
return NextResponse.redirect(new URL('/', req.url));
|
||||
}
|
||||
|
||||
// ✅ Token vorhanden und gültig genug
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@ -61,6 +59,7 @@ export const config = {
|
||||
'/results/:path*',
|
||||
'/notifications/:path*',
|
||||
'/admin/:path*',
|
||||
'/downloads/:path*',
|
||||
'/login',
|
||||
],
|
||||
};
|
||||
|
||||
@ -6,10 +6,11 @@ export type CameraAccessEntry = {
|
||||
};
|
||||
|
||||
export type UserWithAccess = {
|
||||
id: number;
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
expiresAt: string | null;
|
||||
lastLogin?: string | null;
|
||||
cameraAccess: CameraAccessEntry[];
|
||||
features?: ('DOWNLOADS')[];
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user