Compare commits

..

No commits in common. "f87e00ebf3254aac46417f07d2dfae2f90b695a1" and "9a0fff5385b3667366696d195c2c347266a9ed0d" have entirely different histories.

65 changed files with 1107 additions and 5007 deletions

2
.gitignore vendored
View File

@ -1,2 +0,0 @@
backend/node_modules
backend/prisma/dev.db

View File

@ -1,3 +0,0 @@
{
"git.ignoreLimitWarning": true
}

View File

@ -7,4 +7,3 @@ SMTP_PASS=odkxssbmfvewpitv
MAIL_FROM="'SE Düsseldorf' <seduesseldorf@gmail.com>"
UNSUBSCRIBE_SECRET=tegvideo7010!
FRONTEND_ORIGIN=https://sekt.tegdssd.de
#FRONTEND_ORIGIN=https://sekt.local

1436
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"dependencies": {
"@prisma/client": "^6.19.0",
"@prisma/client": "^6.11.0",
"bcrypt": "^6.0.0",
"chokidar": "^4.0.3",
"cookie-parser": "^1.4.7",
@ -11,13 +11,10 @@
"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.19.0"
"prisma": "^6.11.0"
}
}

BIN
backend/prisma/dev.db Normal file

Binary file not shown.

View File

@ -1,10 +0,0 @@
-- 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");

View File

@ -11,19 +11,6 @@ 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
@ -55,7 +42,6 @@ model User {
lastLogin DateTime?
cameraAccess CameraAccess[]
notificationRules NotificationRule[] @relation("UserRules")
features UserFeature[]
}
model CameraAccess {

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
<?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 &amp; 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>

View File

@ -1,13 +0,0 @@
{
"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" }
]
}

View File

@ -14,7 +14,7 @@
"@preline/select": "^3.1.0",
"@preline/theme-switch": "^3.1.0",
"bcrypt": "^6.0.0",
"chart.js": "^4.5.1",
"chart.js": "^4.5.0",
"chartjs-plugin-datalabels": "^2.2.0",
"clipboard": "^2.0.11",
"clsx": "^2.1.1",
@ -22,7 +22,6 @@
"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",
@ -1691,7 +1690,6 @@
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -1759,7 +1757,6 @@
"integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.33.1",
"@typescript-eslint/types": "8.33.1",
@ -2248,7 +2245,6 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2689,11 +2685,10 @@
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@ -3257,7 +3252,6 @@
"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",
@ -3432,7 +3426,6 @@
"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",
@ -3730,24 +3723,6 @@
"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",
@ -5799,7 +5774,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5819,7 +5793,6 @@
"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"
},
@ -6427,18 +6400,6 @@
"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",
@ -6493,8 +6454,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
"integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.2",
@ -6568,7 +6528,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -6718,7 +6677,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -16,7 +16,7 @@
"@preline/select": "^3.1.0",
"@preline/theme-switch": "^3.1.0",
"bcrypt": "^6.0.0",
"chart.js": "^4.5.1",
"chart.js": "^4.5.0",
"chartjs-plugin-datalabels": "^2.2.0",
"clipboard": "^2.0.11",
"clsx": "^2.1.1",
@ -24,7 +24,6 @@
"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",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -110,7 +110,7 @@ export default function LoginPage() {
</h2>
{/* Logout-Grund Alert */}
{logoutNotice && mapped && !error ? (
{logoutNotice && mapped ? (
<div className="relative">
<Alert
title={mapped.title}

View File

@ -1,333 +0,0 @@
// 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>
);
}

View File

@ -1,4 +1,4 @@
// /src/app/(protected)/layout.tsx
// 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} canDownload={user.canDownload} />
<Tabs isAdmin={user.isAdmin} />
<Card className="mt-4 flex-grow flex flex-col">
{children}
</Card>

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Button } from '../../components/Button';
import Modal from '../../components/Modal';
import Table from '../../components/Table';
@ -191,7 +191,8 @@ const BRAND_OPTIONS: string[] = [
"XPENG",
"Zeekr",
"Zhidou",
].sort();
"Andere",
];
/* --------------------------------------------------------- */
/* Haupt-Komponente */
@ -216,10 +217,6 @@ export default function NotiticationsPage() {
recipients: [''],
});
const EMAIL_PATTERN = "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"; // simpel & robust genug
const formEl = useRef<HTMLFormElement>(null);
/* ------------- Regeln laden --------------- */
useEffect(() => {
(async () => {
@ -284,8 +281,6 @@ 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);
@ -365,13 +360,11 @@ 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 border-gray-200 dark:border-neutral-700"
className="flex-1 border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
placeholder="user@example.com"
value={mail}
onChange={e => changeRecipient(idx, e.target.value)}
type='email'
required={!!mail.trim()}
pattern={EMAIL_PATTERN}
/>
{idx === 0 ? (
<Button
@ -404,7 +397,7 @@ export default function NotiticationsPage() {
Kennzeichen enthält
</label>
<input
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 border-gray-200 dark:border-neutral-700"
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
value={form.licensePattern ?? ""}
onChange={e =>
setForm(f => ({
@ -418,7 +411,7 @@ export default function NotiticationsPage() {
<label className="block mb-1">Marke</label>
<ComboBox
id='input-rules-brand'
items={BRAND_OPTIONS}
items={BRAND_OPTIONS.sort()}
value={form.brand ?? ''} // controlled value
onChange={val =>
setForm(f => ({ ...f, brand: val }))
@ -429,7 +422,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 border-gray-200 dark:border-neutral-700"
className="w-full border rounded px-3 py-2 dark:bg-neutral-900 dark:border-neutral-700"
value={form.model ?? ""}
onChange={e =>
setForm(f => ({ ...f, model: e.target.value }))
@ -592,9 +585,7 @@ export default function NotiticationsPage() {
saveButton
onSave={handleSave}
>
<form ref={formEl}>
{renderForm()}
</form>
</Modal>
{/* ---------- Modal: Regel bearbeiten ---- */}
@ -606,9 +597,7 @@ export default function NotiticationsPage() {
onSave={handleSave}
maxWidth="max-w-2xl"
>
<form ref={formEl}>
{renderForm()}
</form>
</Modal>
</div>
);

View File

@ -1,5 +1,3 @@
// /src/app/(protected)/page.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
@ -203,31 +201,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" aspect={2}>
<ChartContainer title="Erkennungen der letzten 7 Tage" height={250} >
<ChartBar data={lastSevenDaysData} />
</ChartContainer>
</div>
<div className="col-span-6">
<ChartContainer title="Stündliche Erkennungen" aspect={2.4}>
<ChartContainer title="Stündliche Erkennungen" height={250}>
<ChartBar data={hourlyData} />
</ChartContainer>
</div>
<div className="col-span-3">
<ChartContainer title="Erfasste Länder" aspect={1}>
<ChartContainer title="Erfasste Länder" height={300}>
<ChartPie data={countryData} />
</ChartContainer>
</div>
<div className="col-span-4">
<ChartContainer title="Top 10 erfasste Fahrzeugmarken" aspect={1.2}>
<ChartContainer title="Top 10 erfasste Fahrzeugmarken" height={300}>
<ChartPie data={brandData} />
</ChartContainer>
</div>
<div className="col-span-3">
<ChartContainer title="Erkennungen pro Kamera" aspect={1}>
<ChartContainer title="Erkennungen pro Kamera" height={300}>
<ChartPie data={cameraData} />
</ChartContainer>
</div>

View File

@ -1,168 +1,46 @@
// /src/app/(protected)/results/page.tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, 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();
const { resetNewCount } = useSSE(); // aus deinem SSE-Context
const [resetNewMarkers, setResetNewMarkers] = useState(false);
// Auswahl & Zoom
const [selected, setSelected] = useState<Recognition | null>(null);
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
// Breakpoint erkennen (xl = 1280px)
const [isXL, setIsXL] = useState(false);
/* ---------------------------------------------------------------
Beim Aufruf der Seite:
neue Treffer-Zähler zurücksetzen
Marker kurz anzeigen, damit das Blinken funktioniert
---------------------------------------------------------------- */
useEffect(() => {
const mq = window.matchMedia('(min-width: 1280px)');
const onChange = () => setIsXL(mq.matches);
fetch('/api/recognitions/reset-count', {
method: 'POST',
credentials: 'include',
});
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();
resetNewCount(); // Zähler im Kontext zurücksetzen
setResetNewMarkers(true);
const t = setTimeout(() => setResetNewMarkers(false), 100);
return () => clearTimeout(t);
}, [resetNewCount]);
/* ---------------------------------------------------------------
Query-Parameter auslesen
---------------------------------------------------------------- */
const initialSearch = searchParams.get('search') ?? '';
const initialPage = parseInt(searchParams.get('page') ?? '1', 10);
// 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]
);
/* ---------------------------------------------------------------
Render
---------------------------------------------------------------- */
return (
<>
<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)} />
)}
</>
);
}

View File

@ -1,21 +0,0 @@
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 });
}
}

View File

@ -16,7 +16,6 @@ type User = {
isAdmin: boolean;
tokenExpiresAt?: number;
lastLogin?: string | null;
features?: ('DOWNLOADS')[];
};
type AuthContextType = {
@ -85,7 +84,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
isAdmin: data.isAdmin,
tokenExpiresAt: data.tokenExpiresAt ?? null,
lastLogin: data.lastLogin ?? null,
features: data.features ?? [],
};
setUser(mapped);

View File

@ -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_minmax(0,1fr)_auto] gap-1 items-center w-full">
<div className="grid grid-cols-[auto_2fr_auto] gap-1 items-center w-full">
{/* Checkbox + Label */}
<div className="relative flex items-start w-full p-4">
<input
@ -120,8 +120,8 @@ const CameraList: React.FC<CameraListProps> = ({
{/* DateRange - nur wenn ausgewählt */}
{checked && (
<div className="flex gap-2 w-full min-w-0">
<div className="flex items-center flex-1 min-w-0">
<div className="flex gap-2">
<div className="flex items-center">
<DatePicker
id={id + camera}
title="Unbegrenzt"
@ -133,12 +133,11 @@ const CameraList: React.FC<CameraListProps> = ({
minDate={minDate}
maxDate={maxDate}
suppressInitialChange={true}
containerClassName="w-full"
/>
</div>
{/* Einzel-Reset-Button */}
<div className="flex items-center shrink-0">
<div className="flex items-center">
<Button
type="button"
size="small"

View File

@ -4,9 +4,13 @@
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;
};
@ -18,19 +22,12 @@ export default function Card({
}: CardProps) {
return (
<div
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={`h-full p-4 flex flex-col bg-white border border-gray-200 shadow-2xs rounded-xl
dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70
${className}`}
>
{(title || action) && (
<div
className={[
'mb-3 flex items-center justify-between gap-4',
title
? '-mx-4 -mt-4 px-4 py-3 bg-white dark:bg-neutral-700 border-b border-gray-200 dark:border-neutral-700'
: ''
].join(' ')}
>
<div className="mb-3 flex items-center justify-between gap-4">
{title && (
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{title}

View File

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

View File

@ -1,91 +0,0 @@
// 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;

View File

@ -1,172 +0,0 @@
// /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>
);
}

View File

@ -1,82 +0,0 @@
'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>
);
}

View File

@ -20,7 +20,7 @@ export default function ConnectionIndicator() {
};
return (
<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]}`}>
<div className={`w-full sm:w-auto text-center p-2 rounded-xl text-sm bg-gray-50 dark:bg-neutral-800 dark:shadow-neutral-700/70 ${colorMap[connectionStatus]}`}>
{labelMap[connectionStatus]}
</div>
);

View File

@ -8,7 +8,6 @@ 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;
@ -59,7 +58,6 @@ export default function DatePicker({
title,
value,
className = '',
containerClassName,
selectionDatesMode = 'multiple-ranged',
disableUnavailableDates = true,
onDateChange,
@ -300,7 +298,7 @@ export default function DatePicker({
}, [calendarInstance, onReset, onDateChange]);
return (
<div className={`flex items-center gap-2 w-full ${containerClassName ?? ''}`}>
<div className="flex items-center gap-2">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="w-4 h-4 text-gray-400 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">

View File

@ -1,24 +0,0 @@
// 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>
);
}

View File

@ -1,69 +1,12 @@
// /src/app/components/Footer.tsx
'use client';
import Changelog from "./Changelog";
import DarkModeToggle from "./DarkModeToggle";
import Modal from "./Modal";
import { useEffect, useState } from "react";
import { 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">
@ -72,33 +15,21 @@ 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>
{/* 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>
<a className="p-1 hover:underline hover:text-gray-800 dark:hover:text-neutral-200" href="#" onClick={() => setIsOpen(true)}>
v2.2
</a>
</li>
)}
<li className="inline-flex items-center relative text-xs text-gray-500 pe-3.5 last:pe-0 last:after:hidden after:absolute after:top-1/2 after:end-0 after:inline-block after:size-[3px] after:bg-gray-400 after:rounded-full after:-translate-y-1/2 dark:text-neutral-500 dark:after:bg-neutral-600">
<DarkModeToggle />
</li>
</ul>
</div>
<Modal open={isOpen} onClose={() => setIsOpen(false)} title="Changelog">
<Changelog />
</Modal>

View File

@ -1,37 +0,0 @@
// 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>
);
}

View File

@ -9,7 +9,6 @@ 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();
@ -41,7 +40,6 @@ export default function HomeClient() {
{tabFromPath === 'dashboard' && <Dashboard />}
{tabFromPath === 'results' && <ResultsPage />}
{tabFromPath === 'notifications' && <NotiticationsPage />}
{tabFromPath === 'downloads' && <DownloadPage />}
{tabFromPath === 'admin' && <Administration />}
</>
);

View File

@ -1,55 +1,19 @@
'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;
};
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
export default function LoadingSpinner({ showBackground = false, showBorder = false }: LoadingSpinnerProps) {
const outerClasses = [
'flex flex-col flex-1 justify-center items-center',
showBackground && 'bg-white shadow-2xs dark:bg-neutral-800 dark:shadow-neutral-700/70',
showBorder && 'border border-gray-200 dark:border-neutral-700',
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(' ');
@ -58,11 +22,7 @@ export default function LoadingSpinner({
<div className={outerClasses}>
<div className="flex justify-center items-center">
<div
className={clsx(
'animate-spin inline-block rounded-full border-current border-t-transparent',
'text-blue-600 dark:text-blue-500',
sizeClasses[size]
)}
className="animate-spin inline-block size-6 border-3 border-current border-t-transparent text-blue-600 rounded-full dark:text-blue-500"
role="status"
aria-label="loading"
>

View File

@ -1,10 +1,8 @@
// Modal.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Button } from './Button';
import LoadingSpinner from './LoadingSpinner';
type ModalProps = {
open: boolean;
@ -12,9 +10,8 @@ type ModalProps = {
title: string;
children: React.ReactNode;
saveButton?: boolean;
onSave?: () => void | Promise<void>;
onSave?: () => void;
maxWidth?: string;
busy?: boolean;
};
export default function Modal({
@ -25,83 +22,35 @@ 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' && !blocked) onClose();
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [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]);
}, [onClose]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 sm:px-0 overscroll-contain"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 sm:px-0"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
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 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">
<div
className={`bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full overflow-hidden flex flex-col max-h-[90vh] ${maxWidth ?? 'max-w-lg'}`}
>
{/* Header */}
<div className="flex justify-between items-center px-4 py-3 border-b border-gray-200 dark:border-neutral-700">
<h2 id="modal-title" className="text-lg font-bold text-gray-800 dark:text-white">
{title}
</h2>
<button
onClick={() => { 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"
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-500 dark:text-neutral-400 cursor-pointer"
aria-label="Schließen"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@ -113,40 +62,28 @@ export default function Modal({
</div>
{/* Body */}
<div className={`p-4 overflow-y-auto text-sm text-gray-700 dark:text-neutral-300 ${blocked ? 'cursor-wait' : ''}`}>
<div className="p-4 overflow-y-auto text-sm text-gray-700 dark:text-neutral-300">
{children}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-neutral-700">
<Button
onClick={() => { if (!blocked) onClose(); }}
color="white"
size="small"
variant="solid"
disabled={blocked}
onClick={onClose}
color='white'
size='small'
variant='solid'
>
Schließen
</Button>
{saveButton && (
<Button
onClick={handleSaveClick}
color="teal"
size="small"
variant="solid"
disabled={blocked}
aria-busy={blocked || undefined}
className="inline-flex items-center gap-2"
onClick={onSave}
color='teal'
size='small'
variant='solid'
>
{blocked ? (
<>
<LoadingSpinner inline size="sm" className="text-current" />
Speichern
</>
) : (
<>💾 Speichern</>
)}
💾 Speichern
</Button>
)}
</div>

View File

@ -1,4 +1,4 @@
// /src/app/components/Pagination.tsx
// 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-black/10 dark:text-white dark:hover:bg-white/10'
: 'text-gray-800 hover:bg-gray-100 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-black/10 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-gray-100 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-black/10 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-gray-100 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-black/10 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-gray-100 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-black/10 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-gray-100 disabled:opacity-50 dark:text-white dark:hover:bg-white/10"
>
<ChevronsRight className="size-3.5 shrink-0" />
</button>

View File

@ -1,4 +1,5 @@
// /src/app/components/RecognitionDetails.tsx
// components/RecognitionDetails.tsx
'use client';
import { useState, useEffect } from 'react';
@ -13,50 +14,18 @@ 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);
// 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);
}
};
}, []);
// id ändert sich bei jeder Auswahl
useEffect(() => setImgLoaded(false), [entry.id]);
useEffect(() => setPlateImgLoaded(false), [entry.id]);
const content = (
<>
return (
<Card title={`Details zum Kennzeichen ${entry.license}`}>
<div className="relative inline-block mb-3">
<Image
src={`/images/${entry.imageFile}`}
@ -68,6 +37,8 @@ export default function RecognitionDetails({
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} />
@ -89,6 +60,8 @@ export default function RecognitionDetails({
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} />
@ -129,15 +102,20 @@ export default function RecognitionDetails({
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>
@ -148,15 +126,6 @@ export default function RecognitionDetails({
<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}</>
);
}

View File

@ -3,96 +3,44 @@
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;
// neu für Checkbox:
checked?: boolean;
onToggle?: () => void;
isSelected: boolean;
isNew: boolean;
onClick: () => void;
};
export default function RecognitionRow({
entry,
isSelected = false,
isNew = false,
onClick,
checked = false,
onToggle,
}: Props) {
export default function RecognitionRow({ entry, isSelected, isNew, onClick }: Props) {
const [animatedConfidence, setAnimatedConfidence] = useState(0);
useEffect(() => {
if (isNew) {
setAnimatedConfidence(0);
setTimeout(() => setAnimatedConfidence(entry.confidence ?? 0), 50);
setTimeout(() => {
setAnimatedConfidence(entry.confidence ?? 0);
}, 50); // Trigger animation
} else {
setAnimatedConfidence(entry.confidence ?? 0);
}
}, [entry.confidence, isNew]);
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'
: ''
: '';
const baseClass = 'cursor-pointer';
const selectedClass = isSelected && !isNew ? 'bg-gray-200 dark:bg-neutral-700' : '';
const hoverClass = !isSelected ? 'hover:bg-gray-100 dark:hover:bg-neutral-600' : '';
const newClass = isNew ? 'bg-green-50 dark:bg-green-600' : '';
return (
<tr onClick={onClick} className={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">
<tr onClick={onClick} className={[baseClass, selectedClass, hoverClass, newClass].filter(Boolean).join(' ')}>
<td className="p-3 font-medium text-gray-800 dark:text-neutral-200">{entry.licenseFormatted}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.country ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.brand ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.model ?? ''}</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">
<Progress value={animatedConfidence} />
</td>
{/* 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 && (
<td className="p-3 text-gray-600 dark:text-neutral-300">
{entry.directionDegrees && entry.direction && (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
@ -101,21 +49,28 @@ export default function RecognitionRow({
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="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
{dirText}
<td className="p-3 text-gray-600 dark:text-neutral-300">
<div className="flex items-center gap-1 text-sm">
{entry.direction === 'Away'
? 'abfahrend'
: entry.direction === 'Towards'
? 'ankommend'
: ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
<td className="p-3 text-gray-600 dark:text-neutral-300">
{new Date(entry.timestampLocal).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-neutral-300">
{entry.cameraName ?? ''}
</td>
<td className="p-3 text-gray-600 dark:text-neutral-300">{entry.cameraName ?? ''}</td>
</tr>
);
}

View File

@ -1,181 +1,49 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { Recognition } from '../../types/plates';
import { Pagination } from './Pagination';
import ImageZoomModal from './ImageZoomModal';
import RecognitionRow from './RecognitionRow';
import RecognitionDetails from './RecognitionDetails';
import Table from './Table';
import RecognitionsTableFilters from './RecognitionsTableFilters';
import { useSSE } from './SSEContext';
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;
};
// 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) {
export default function RecognitionsTable({ resetNewMarkers, initialPage, initialSearch }: Props) {
const [data, setData] = useState<Recognition[]>([]);
const [selected, setSelected] = useState<Recognition | null>(null);
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [currentPage, setCurrentPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(1);
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [newestId, setNewestId] = useState<number | null>(null);
const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({
from: null,
to: null
});
const [directionFilter, setDirectionFilter] = useState<string>('');
const [cameraFilter, setCameraFilter] = useState<string>(''); // '' = alle
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
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 { onNewRecognition } = useSSE();
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);
/** 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(() => {
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
// Lese Query-Parameter aus URL
useEffect(() => {
const pageParam = searchParams.get('page');
const searchParam = searchParams.get('search');
const from = searchParams.get('timestampFrom');
const to = searchParams.get('timestampTo');
const directionParam = searchParams.get('direction');
const cameraParam = searchParams.get('camera');
if (pageParam) setCurrentPage(parseInt(pageParam));
if (searchParam) setSearchTerm(searchParam);
@ -186,21 +54,19 @@ export default function RecognitionsTable({
});
}
if (directionParam) setDirectionFilter(directionParam);
if (cameraParam !== null) setCameraFilter(cameraParam); // '' oder Name
}, [searchParams]);
// URL aktuell halten
// URL bei Interaktion aktualisieren
useEffect(() => {
const params = new URLSearchParams();
if (searchTerm) params.set('search', searchTerm);
if (currentPage > 1) params.set('page', String(currentPage));
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 (cameraFilter) params.set('camera', cameraFilter);
router.replace(`${pathname}?${params.toString()}`);
}, [searchTerm, currentPage, dateRange, directionFilter, cameraFilter, pathname, router]);
}, [searchTerm, currentPage, dateRange, directionFilter, pathname, router]);
useEffect(() => {
if (resetNewMarkers) {
@ -219,7 +85,6 @@ export default function RecognitionsTable({
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())
@ -227,11 +92,9 @@ export default function RecognitionsTable({
if (Array.isArray(json.data)) {
setData(json.data);
setTotalPages(json.totalPages || 1);
setTotalMatching(json.totalCount || 0);
} else {
setData([]);
setTotalPages(1);
setTotalMatching(0);
}
})
.catch((err) => {
@ -239,20 +102,19 @@ export default function RecognitionsTable({
setData([]);
setTotalPages(1);
});
}, [currentPage, searchTerm, dateRange, directionFilter, cameraFilter]);
}, [currentPage, searchTerm, dateRange, directionFilter]);
useEffect(() => {
let timeoutId: number | null = null;
const cb = (newEntry: Recognition) => {
if (currentPage === 1) {
setData((prev) => {
const alreadyExists = prev.some(entry => entry.id === newEntry.id);
if (alreadyExists) return prev;
const off = onNewRecognition((newEntry: Recognition) => {
if (currentPage !== 1) return;
setData(prev => {
if (prev.some(entry => entry.id === newEntry.id)) return prev;
return [newEntry, ...prev].slice(0, itemsPerPage);
});
fetch(`/api/recognitions/count`, { credentials: 'include' })
fetch(`/api/recognitions/count`, { credentials: "include" })
.then(res => res.json())
.then(({ count }) => {
const pages = Math.max(1, Math.ceil(count / itemsPerPage));
@ -261,286 +123,35 @@ export default function RecognitionsTable({
.catch(console.error);
setNewestId(newEntry.id);
if (timeoutId) window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
setNewestId(prevId => (prevId === newEntry.id ? null : prevId));
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="space-y-4">
{/* Linke Spalte: Filter/Toolbar/Tabelle/Pagination */}
<div className="min-w-0 flex flex-col">
<div className="grid gap-4 grid-cols-1 md:grid-cols-4 xl:grid-cols-6">
<div className="col-span-6">
<RecognitionsTableFilters
searchTerm={searchTerm}
directionFilter={directionFilter}
selectedCamera={cameraFilter}
setSearchTerm={setSearchTerm}
setDirectionFilter={setDirectionFilter}
setDateRange={setDateRange}
setCurrentPage={setCurrentPage}
setSelectedCamera={setCameraFilter}
dateRange={dateRange}
/>
{/* 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 */}
<div className="col-span-6 md:col-span-3 xl:col-span-4">
<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>
@ -556,18 +167,14 @@ export default function RecognitionsTable({
<RecognitionRow
key={entry.id}
entry={entry}
isSelected={selectedRow?.id === entry.id}
isSelected={selected?.id === entry.id}
isNew={newestId === entry.id}
onClick={() =>
setSelectedRow(selectedRow?.id === entry.id ? null : entry)
}
checked={isSelected(entry.id)}
onToggle={() => toggleRow(entry.id)}
onClick={() => setSelected(entry)}
/>
))}
{data.length === 0 && (
<tr>
<td colSpan={10} className="p-5 text-center text-gray-500 dark:text-neutral-400">
<td colSpan={7} className="p-5 text-center text-gray-500 dark:text-neutral-400">
Keine Daten gefunden.
</td>
</tr>
@ -577,104 +184,18 @@ export default function RecognitionsTable({
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={goToPage} />
</div>
{/* 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 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>
<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>
{fullscreenImage && (
<ImageZoomModal src={fullscreenImage} onClose={() => setFullscreenImage(null)} />
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
// RecognitionsTableFilters.tsx
'use client';
import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect } from 'react';
import DatePicker from './DatePicker';
import { Button } from './Button';
import Select from './Select';
@ -9,13 +9,11 @@ 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>>;
dateRange: { from: Date | null; to: Date | null };
selectedCamera?: string | null;
};
export default function RecognitionsTableFilters({
@ -23,101 +21,67 @@ export default function RecognitionsTableFilters({
directionFilter,
setSearchTerm,
setDirectionFilter,
setSelectedCamera,
setDateRange,
setCurrentPage,
selectedCamera,
dateRange
selectedCamera
}: 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[]>([]);
// 👉 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
// 📅 Zeitbereich 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;
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]);
// 👉 wenn per URL ein Zeitraum kommt, direkt anzeigen und den Zurücksetzen-Button aktivieren
useEffect(() => {
setHasFilter(!!dateRange?.from || !!dateRange?.to);
}, [dateRange]);
}, [selectedCamera]); // neu laden, wenn der User eine andere Kamera wählt
// 📆 Zeitbereich
const handleDateRangeChange = useCallback((range: { from: Date | null; to: Date | null }) => {
setDateRange(range);
setHasFilter(!!range.from || !!range.to);
setCurrentPage(1);
}, [setDateRange, setCurrentPage]);
// 🔄 Zurücksetzen
const handleReset = () => {
resetDatePickerRef.current?.();
resetDatePickerRef.current?.(); // DatePicker leeren
// Dann die lokalen States aktualisieren
setDateRange({ from: null, to: null });
setHasFilter(false);
setCurrentPage(1);
};
const handleDirectionChange = (selected: string[]) => {
// 📥 Auswahländerung bei Richtung
const handleSelectionChange = (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="search"
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
@ -126,40 +90,12 @@ 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 */}
@ -168,8 +104,6 @@ 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}
@ -204,27 +138,10 @@ export default function RecognitionsTableFilters({
]}
selected={directionFilter}
multiple={false}
placeholder="Richtung auswählen"
onChange={handleDirectionChange}
placeholder="Richtung auswählen..."
onChange={handleSelectionChange}
/>
</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>
);
}

View File

@ -1,23 +1,13 @@
// app/components/SSEContext.tsx
// app/components/SSEContext.tsx ← vormals SSEContext.tsx
'use client';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useCurrentUser } from './AuthContext';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { useCurrentUser } from './AuthContext'; // ◄ NEU
import { Recognition } from '@/types/plates';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
type 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
onNewRecognition: (cb: (r: Recognition) => void) => void;
connectionStatus: ConnectionStatus;
newCount: number;
resetNewCount: () => void;
@ -26,89 +16,57 @@ type SSEContextType = {
const SSEContext = createContext<SSEContextType | undefined>(undefined);
export function SSEProvider({ children }: { children: React.ReactNode }) {
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 { user } = useCurrentUser(); // ◄ NEU
const listeners = useRef<((r: Recognition) => void)[]>([]);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [newCount, setNewCount] = useState(0);
/* ───────── EventSource an/abmelden ──────────────────────────────── */
useEffect(() => {
if (!user) {
if (!user) { // noch nicht eingeloggt → keine Verbindung
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) => {
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('new-recognition', e => {
const rec = JSON.parse(e.data) as Recognition;
listeners.current.forEach(cb => cb(rec));
setNewCount(c => c + 1);
});
// 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 {}
});
// Server-initiierter Logout
es.addEventListener('logout', (e) => {
try {
const { reason } = JSON.parse((e as MessageEvent).data);
es.addEventListener('logout', e => {
const { reason } = JSON.parse(e.data);
console.info('Server verlangt Logout:', reason);
} catch {}
fetch('/api/logout', { method: 'POST', credentials: 'include' })
.finally(() => (window.location.href = '/login'));
fetch(`/api/logout`,
{ method: 'POST', credentials: 'include' })
.finally(() => window.location.href = '/login');
});
es.onerror = (err) => {
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]); // 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);
}, []);
}, [user, user?.id]); // ◄ Effekt läuft nur, wenn sich der Benutzer ändert
/* ───────── Context-Objekt ───────────────────────────────────────── */
const ctx: SSEContextType = {
onNewRecognition,
onExportProgress, // ← im Frontend verwenden
onNewRecognition: cb => listeners.current.push(cb),
connectionStatus: status,
newCount,
resetNewCount: () => setNewCount(0),

View File

@ -1,5 +1,3 @@
// /src/app/components/Table.tsx
import { ReactNode, HTMLAttributes } from 'react';
export function Table({ children }: { children: ReactNode }) {
@ -19,11 +17,11 @@ export function Table({ children }: { children: ReactNode }) {
}
function Head({ children }: { children: ReactNode }) {
return <thead className="bg-white dark:bg-neutral-700">{children}</thead>;
return <thead className="bg-gray-50 dark:bg-neutral-700">{children}</thead>;
}
function Body({ children }: { children: ReactNode }) {
return <tbody className="bg-neutral-50 dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">{children}</tbody>;
return <tbody className="divide-y divide-gray-200 dark:divide-neutral-700">{children}</tbody>;
}
function Row({

View File

@ -1,19 +1,16 @@
// /src/app/components/Tabs.tsx
'use client';
import { useRouter, usePathname } from 'next/navigation';
import React from 'react';
type TabKey = 'dashboard' | 'results' | 'notifications' | 'downloads' | 'admin';
type TabKey = 'dashboard' | 'results' | 'notifications' | 'admin';
type TabsProps = {
newCount?: number;
isAdmin: boolean;
canDownload?: boolean;
};
export default function Tabs({ newCount = 0, isAdmin = false, canDownload = false }: TabsProps) {
export default function Tabs({ newCount = 0, isAdmin = false }: TabsProps) {
const router = useRouter();
const pathname = usePathname();
const activePath = pathname === '' ? '/' : pathname;
@ -54,31 +51,8 @@ export default function Tabs({ newCount = 0, isAdmin = false, canDownload = fals
</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 ? [{
...(isAdmin
? [{
key: 'admin' as const,
label: 'Administration',
href: '/admin',
@ -88,7 +62,8 @@ export default function Tabs({ newCount = 0, isAdmin = false, canDownload = fals
<circle cx="12" cy="12" r="3" />
</svg>
),
}] : []),
}]
: []),
];
return (

View File

@ -1,5 +1,3 @@
// /src/app/components/ThemeProvider.tsx
'use client';
import { useState, useEffect, createContext, useContext } from 'react';

View File

@ -1,4 +1,4 @@
// components/UserForm.tsx
// UserForm.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
@ -9,9 +9,6 @@ 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('');
@ -19,56 +16,82 @@ 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(e => (e.camera === camera ? { ...e, [field]: value } : e)));
setSelectedCameras(prev =>
prev.map(entry =>
entry.camera === camera ? { ...entry, [field]: value } : entry
)
);
},
[]
);
async function handleSubmit(e: React.FormEvent) {
/*
Benutzer anlegen
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const cameraAccess = selectedCameras.map(({ camera, from, to }) => ({
camera,
from: from ? from.toISOString() : null,
to: to ? to.toISOString() : null,
to: to ? to.toISOString() : null
}));
const res = await fetch(`/api/admin/create-user`, {
@ -78,9 +101,8 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
body: JSON.stringify({
username,
expiresAt: expiresAt ? expiresAt.toISOString().split('T')[0] : null,
cameraAccess,
features: allowDownloads ? ['DOWNLOADS'] : [],
}),
cameraAccess
})
});
const data = await res.json();
@ -91,33 +113,47 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
onUserCreated();
}
// Formular zurücksetzen
setUsername('');
setExpiresAt(null);
setSelectedCameras([]);
setAllowDownloads(false);
}
};
/*
RENDER
*/
return (
<>
<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">
<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 h-11 px-3 border-gray-200 rounded-lg text-base
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-200"
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
/>
</Field>
</div>
<Field label="Ablaufdatum" className="sm:col-span-4">
{/* ── 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"
@ -126,30 +162,16 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
minDate={new Date()}
maxDate={new Date(new Date().setFullYear(new Date().getFullYear() + 10))}
disablePastDates
className="py-2"
className="py-3"
/>
</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>
{/* 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"
>
{/* ── Spalte 3: Kamera-Liste ─── */}
<div className="min-w-0">
<label className="block text-sm font-medium mb-2 dark:text-white">
Zugriff auf Kameras
</label>
<CameraList
idPrefix="form"
cameraOptions={cameraOptions}
@ -160,11 +182,12 @@ export default function UserForm({ onUserCreated }: { onUserCreated: () => void
handleDateChange={handleDateChange}
isLoading={isLoading}
/>
</FormSection>
</div>
{/* 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">
{/* ── Spalte 4: Button rechts/unten ───────── */}
<div className="justify-self-end self-center w-full">
<label htmlFor="" className="block text-sm font-medium mb-2 dark:text-white">&nbsp;</label>
<Button type="submit" variant="solid" color="blue" className='w-full justify-center'>
Benutzer anlegen
</Button>
</div>

View File

@ -26,7 +26,7 @@ function formatTimeLeft(seconds: number): string {
}
// Texte zentral halten, falls mehrfach benötigt
const LOGOUT_REASON_MANUAL = 'Du hast dich erfolgreich abgemeldet.';
const LOGOUT_REASON_MANUAL = 'Du hast dich abgemeldet.';
const LOGOUT_REASON_INACTIVE = 'Du wurdest wegen Inaktivität automatisch abgemeldet.';
const LOGOUT_REASON_EXPIRED = 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.';

View File

@ -12,7 +12,6 @@ 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();
@ -30,13 +29,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 () => {
@ -52,30 +51,41 @@ 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: string) => {
await fetch(`/api/admin/block-user/${userId}`, { method: 'POST', credentials: 'include' });
const handleBlockAccess = async (userId: number) => {
await fetch(`/api/admin/block-user/${userId}`, {
method: 'POST',
credentials: 'include',
});
fetchUsers();
};
const handleDeleteUser = async (userId: string) => {
const handleDeleteUser = async (userId: number) => {
if (!confirm('Soll dieser Benutzer wirklich gelöscht werden?')) return;
await fetch(`/api/admin/delete-user/${userId}`, { method: 'DELETE', credentials: 'include' });
await fetch(`/api/admin/delete-user/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
fetchUsers();
};
@ -83,8 +93,11 @@ 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) {
@ -112,10 +125,6 @@ export default function UserTable() {
...user,
expiresAt: user.expiresAt?.slice(0, 10) ?? ''
});
// ⬇️ NEU: Checkbox mit aktuellem Featurezustand vorbelegen
setEditAllowDownloads(!!user.features?.includes('DOWNLOADS'));
setEditUserModalOpen(true);
};
@ -133,9 +142,7 @@ export default function UserTable() {
camera,
from,
to,
})),
// ⬇️ NEU: Features mitsenden
features: editAllowDownloads ? ['DOWNLOADS'] : [],
}))
}),
});
@ -155,9 +162,15 @@ 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
)
);
};
@ -169,7 +182,6 @@ 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>
@ -222,13 +234,6 @@ 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'}
@ -251,7 +256,6 @@ export default function UserTable() {
</Table.Body>
</Table>
{/* Neues Passwort Modal */}
<Modal
open={showPasswordModal}
onClose={() => {
@ -274,7 +278,6 @@ export default function UserTable() {
</div>
</Modal>
{/* Edit-Modal mit Downloads-Checkbox */}
<Modal
open={editUserModalOpen}
onClose={() => setEditUserModalOpen(false)}
@ -293,7 +296,13 @@ export default function UserTable() {
type="text"
value={editUserData.username}
onChange={(e) =>
setEditUserData((prev) => prev ? { ...prev, username: e.target.value } : prev)
setEditUserData((prev) => {
if (!prev) return prev;
return {
...prev,
username: e.target.value,
};
})
}
className="w-full border rounded-lg px-3 py-2.5 dark:bg-neutral-900 dark:border-neutral-700 dark:text-white"
/>
@ -307,12 +316,15 @@ export default function UserTable() {
value={editUserData.expiresAt ?? undefined}
suppressInitialChange
onDateChange={({ from }) =>
setEditUserData((prev) => prev ? {
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,
} : prev)
};
})
}
disablePastDates={true}
minDate={new Date()}
@ -333,19 +345,6 @@ 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>
)}

View File

View File

@ -1,13 +1,18 @@
// /src/app/components/charts/ChartBar.tsx
'use client';
import { useEffect, useMemo, useRef } from 'react';
import Chart from 'chart.js/auto';
import type { ChartOptions, TooltipItem, ChartData } from 'chart.js';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Title,
TooltipItem
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import useIsDark from '@/app/hooks/useIsDark';
Chart.register(ChartDataLabels);
import { useMemo } from 'react';
export type DayCount = { date: string; count: number };
@ -18,88 +23,97 @@ export type PlateCount = {
model?: string;
};
type ChartBarProps = { data: DayCount[]; horizontal?: boolean };
type ChartBarProps = {
data: DayCount[];
horizontal?: boolean;
};
ChartJS.register(BarElement, CategoryScale, LinearScale, ChartDataLabels, Tooltip, Legend, Title);
export default function ChartBar({ data, horizontal = false }: ChartBarProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart<'bar'> | null>(null);
const isDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const isDark = useIsDark();
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),
const chartData = useMemo(() => ({
labels: data.map(d => d.date),
datasets: [
{
type: 'bar',
label: 'Erkennungen',
data: data.map((d) => d.count),
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]
);
borderRadius: 4
}
]
}), [data, isDark]);
const options = useMemo<ChartOptions<'bar'>>(() => ({
indexAxis: horizontal ? 'y' : 'x',
const options = useMemo(() => ({
indexAxis: (horizontal ? 'y' : 'x') as 'x' | 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: TooltipItem<'bar'>) => {
const v = ctx.raw as number;
return `Erkennungen: ${v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v}`;
label: (context: TooltipItem<'bar'>) => {
const value = context.raw as number;
return `Erkennungen: ${value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value}`;
}
}
},
datalabels: {
color: isDark ? '#f3f4f6' : '#1f2937',
anchor: (horizontal ? 'end' : 'center') as 'center' | 'end',
align: (horizontal ? 'right' : 'top') as 'right' | 'top',
offset: horizontal ? 4 : -6,
font: {
weight: 'bold' as const,
size: 12
},
layout: {
padding: { top: topPadding, right: rightPadding, bottom: 4, left: 4 }
formatter: (value: number) => (value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value),
}
},
scales: {
x: {
ticks: { color: isDark ? '#a3a3a3' : '#374151', font: { size: 13 } },
grid: { color: isDark ? '#404040' : '#e5e7eb' },
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 }
},
},
}), [horizontal, isDark, topPadding, rightPadding]);
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%' }} />;
grid: {
color: isDark ? '#404040' : '#e5e7eb'
}
}
},
animations: {
x: horizontal
? {
type: 'number' as const,
easing: 'easeOutCubic' as const,
duration: 800,
from: 0
}
: undefined,
y: !horizontal
? {
type: 'number' as const,
easing: 'easeOutCubic' as const,
duration: 800,
from: 0
}
: undefined
}
}), [horizontal, isDark]);
return (
<Bar data={chartData} options={options} />
);
}

View File

@ -1,4 +1,3 @@
// /src/app/components/charts/ChartContainer.tsx
'use client';
import React from 'react';
@ -6,50 +5,23 @@ 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,
aspect,
fill = false,
height = 'auto',
className = '',
children,
minHeight = 180,
children
}: ChartContainerProps) {
const useAspect = !fill && height == null && aspect != null && aspect > 0;
return (
<Card className={`w-full h-full ${className}`}>
<Card className={`w-full ${className}`}>
{title && <h2 className="text-xl font-semibold mb-2">{title}</h2>}
{fill ? (
// Füllt die komplette zur Verfügung stehende Höhe
<div className="relative w-full h-full" style={{ minHeight }}>
<div style={{ height }} className="relative w-full">
{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>
);
}

View File

@ -1,15 +1,19 @@
// /src/app/components/charts/ChartPie.tsx
'use client';
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 { 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 useIsDark from '@/app/hooks/useIsDark';
import ChartDataLabels from 'chartjs-plugin-datalabels';
Chart.register(ChartDataLabels);
ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels);
type ChartPieProps = {
data: DayCount[];
@ -17,19 +21,18 @@ type ChartPieProps = {
};
export default function ChartPie({ data, legend = true }: ChartPieProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart<'pie'> | null>(null);
const isDark =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = useIsDark();
const chartData = useMemo<ChartData<'pie', number[], string>>(() => {
const chartData = useMemo(() => {
const labels = data.map(d => d.date);
const series = data.map(d => d.count);
return {
labels,
datasets: [{
type: 'pie', // <- sauber getypt (vermeidet TS-Fehler)
datasets: [
{
label: 'Erkennungen',
data: series,
backgroundColor: [
@ -39,58 +42,62 @@ export default function ChartPie({ data, legend = true }: ChartPieProps) {
],
borderColor: isDark ? '#1f2937' : '#ffffff',
borderWidth: 1
}]
}
]
};
}, [data, isDark]);
const options = useMemo<ChartOptions<'pie'>>(() => ({
const options: ChartOptions<'pie'> = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: legend,
position: 'bottom',
labels: { color: isDark ? '#a3a3a3' : '#4b5563' }
labels: {
color: isDark ? '#a3a3a3' : '#4b5563'
}
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.label ?? ''}: ${ctx.raw as number} Erkennungen`
label: (context) => {
const value = context.raw;
const label = context.label;
return `${label}: ${value} Erkennungen`;
}
}
},
datalabels: {
color: isDark ? '#f3f4f6' : '#111827',
font: { weight: 'normal', size: 12 },
color: isDark ? '#d1d5db' : '#374151',
font: {
weight: 'bold' as const,
size: 12
},
anchor: 'end',
align: 'start',
offset: 6,
clamp: true,
clip: false,
display: (ctx) => {
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
}
const dataset = ctx.chart.data.datasets[0] as ChartDataset<'pie', number[]>;;
const total = dataset.data.reduce((sum: number, val: number) => sum + val, 0);
const value = dataset.data[ctx.dataIndex] as number;
return value / total >= 0.03;
}
},
layout: {
padding: 20
},
clip: false,
},
animation: {
animateRotate: true,
animateScale: true,
duration: 800,
easing: 'easeOutCubic'
},
layout: { padding: 20 }
}
}), [isDark, legend]);
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%' }} />;
return (
<Pie data={chartData} options={options} />
);
}

View File

@ -123,6 +123,8 @@ body {
color: var(--vc-text-light) !important;
}
.vc-date {
width: 42px !important;
}

View File

@ -1,56 +0,0 @@
// /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;
}

View File

@ -1,5 +1,3 @@
// /src/app/unsubscribe/page.tsx
'use client';
import { useState, useEffect } from 'react';
@ -32,8 +30,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=None; Secure';
document.cookie = 'unsubscribeRule=; Path=/; Max-Age=0; SameSite=None; Secure';
document.cookie = 'unsubscribeStatus=; Path=/; Max-Age=0; SameSite=Lax;';
document.cookie = 'unsubscribeRule=; Path=/; Max-Age=0; SameSite=Lax;';
setLoading(false); // <--- Ladezustand beenden
}, 100);

View File

@ -1,38 +1,35 @@
// /src/lib/auth.ts
import { cookies } from 'next/headers'
import { fetch, Agent } from 'undici'
import { cookies } from 'next/headers';
import { fetch, Agent } from 'undici'; // ⬅️ Agent von 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 },
})
connect: {
rejectUnauthorized: false, // self-signed Zertifikate akzeptieren
},
});
type ApiMeResponse = {
id: string
username: string
isAdmin: boolean
tokenExpiresAt?: number
features?: ('DOWNLOADS')[]
}
export type User = ApiMeResponse & {
canDownload: boolean
}
export type User = {
id: string;
username: string;
isAdmin: boolean;
tokenExpiresAt?: number;
};
export async function getServerUser(): Promise<User | null> {
const cookieStore = await cookies()
const cookieHeader = cookieStore.toString()
if (!cookieHeader) return null
const cookieStore = await cookies(); // synchron
const cookieHeader = cookieStore.toString();
if (!cookieHeader) return null;
const res = await fetch(`${BASE_URL}/api/me`, {
headers: { cookie: cookieHeader },
dispatcher: agent,
})
if (!res.ok) return null
headers: {
cookie: cookieHeader,
},
dispatcher: agent, // ✔️ funktioniert jetzt
});
const payload = (await res.json()) as ApiMeResponse
const canDownload = !!payload.features?.includes('DOWNLOADS')
if (!res.ok) return null;
return { ...payload, canDownload }
return res.json() as Promise<User>;
}

View File

@ -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 melde dich erneut an.';
return 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.';
case 'server':
return 'Du wurdest vom Server abgemeldet.';
case 'error':
return 'Du wurdest abgemeldet (technischer Fehler).';
case 'manual':
default:
return 'Du hast dich erfolgreich abgemeldet.';
return 'Du hast dich abgemeldet.';
}
}

View File

@ -1,13 +1,17 @@
// /src/middleware.ts
import { JwtPayload } from 'jsonwebtoken';
import { NextRequest, NextResponse } from 'next/server';
// Pfade, die öffentlich zugänglich sind
//const PUBLIC_PATHS = ['/login'];
// Statische Assets ignorieren
const PUBLIC_FILE = /\.(.*)$/;
function decodeJwtPayload(token: string) {
function decodeJwtPayload(token: string): JwtPayload | null {
try {
const part = token.split('.')[1] || '';
const base64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '=');
return JSON.parse(atob(base64));
const base64Payload = token.split('.')[1];
const payload = atob(base64Payload);
return JSON.parse(payload);
} catch {
return null;
}
@ -18,7 +22,7 @@ export function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
const payload = token ? decodeJwtPayload(token) : null;
// Ignoriere API & statics
// 🔓 Ignoriere API, static, _next etc.
if (
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
@ -28,28 +32,26 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
// Login-Seite: wenn eingeloggt → weiter
if (pathname === '/login') return NextResponse.next();
// 🔓 Login-Seite: Umleiten, wenn bereits eingeloggt
if (pathname === '/login') {
if (payload) {
const redirectTo = payload.isAdmin ? '/admin' : '/';
return NextResponse.redirect(new URL(redirectTo, req.url));
}
return NextResponse.next();
}
// Kein Token → Login
// 🔐 Kein Token → redirect zu Login
if (!token || !payload) {
return NextResponse.redirect(new URL('/login', req.url));
}
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)) {
// 🔒 Admin-Check
if (pathname.startsWith('/admin') && !payload.isAdmin) {
return NextResponse.redirect(new URL('/', req.url));
}
// ✅ Token vorhanden und gültig genug
return NextResponse.next();
}
@ -59,7 +61,6 @@ export const config = {
'/results/:path*',
'/notifications/:path*',
'/admin/:path*',
'/downloads/:path*',
'/login',
],
};

View File

@ -6,11 +6,10 @@ export type CameraAccessEntry = {
};
export type UserWithAccess = {
id: string;
id: number;
username: string;
isAdmin: boolean;
expiresAt: string | null;
lastLogin?: string | null;
cameraAccess: CameraAccessEntry[];
features?: ('DOWNLOADS')[];
};