This commit is contained in:
Linrador 2026-01-29 11:47:40 +01:00
parent f87e00ebf3
commit a1e3d5022a
7 changed files with 651 additions and 49 deletions

View File

@ -1,4 +1,4 @@
// server.js
// backend\server.js
const fs = require('fs');
const path = require('path');
const express = require('express');
@ -72,6 +72,12 @@ let lastResetTimestamp = new Date();
// === Funktionen ===
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function pushLogout(userId, reason = 'expired') {
const set = sseClients.get(userId);
if (!set) return;
@ -393,6 +399,36 @@ async function findImageFile(filename) {
return searchInDir(WATCH_PATH);
}
async function findSidecarXmlFromMediaFilename(filename) {
if (!filename) return null;
// Basisname aus Bild oder Plate ableiten:
// - "abc123.jpg" -> "abc123_Info.xml"
// - "abc123_plate.jpg" -> "abc123_Info.xml"
const parsed = path.parse(filename);
const base = parsed.name.replace(/_plate$/i, ''); // _plate abschneiden
const xmlFilename = `${base}_Info.xml`;
function searchInDir(dir) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = searchInDir(full);
if (found) return found;
} else if (entry.isFile() && entry.name === xmlFilename) {
return full;
}
}
} catch { /* still */ }
return null;
}
return searchInDir(WATCH_PATH);
}
async function processFile(filePath) {
try {
const xml = fs.readFileSync(filePath, 'utf-8');
@ -1545,6 +1581,140 @@ app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => {
}
});
// ✅ Admin: Erkennungen löschen (mit Vorschau / Dry-Run + Progress über SSE)
// Body: { camera?: string, from?: string, to?: string, dryRun?: boolean, alsoFiles?: boolean, clientJobId?: string }
app.post('/api/admin/recognitions/purge', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' });
try {
let { camera, from, to, dryRun = true, alsoFiles = false, clientJobId } = req.body || {};
const where = {};
if (camera && typeof camera === 'string' && camera.trim()) {
where.cameraName = camera.trim();
}
if (from || to) {
where.timestampLocal = {};
if (from) where.timestampLocal.gte = new Date(from);
if (to) where.timestampLocal.lte = endOfDay(to);
}
if (!where.cameraName && !where.timestampLocal) {
return res.status(400).json({ error: 'Bitte mindestens Kamera oder Zeitraum angeben.' });
}
// Vorab: Kandidaten einsammeln (IDs & Dateien), damit wir Batches + Files managen können
const rows = await prisma.recognition.findMany({
where,
select: { id: true, imageFile: true, plateFile: true }
});
if (dryRun) {
console.log(`🧪 [purge:preview] user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} matches=${rows.length}`);
return res.json({
dryRun: true,
matchCount: rows.length,
sampleIds: rows.slice(0, 10).map(r => r.id),
camera: camera ?? null,
from: from ?? null,
to: to ?? null
});
}
const userId = String(req.user.id);
const jobId = clientJobId || crypto.randomUUID();
const total = rows.length;
console.log(`🧨 [purge:start] job=${jobId} user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} alsoFiles=${!!alsoFiles} total=${total}`);
// Schnell-Fall: nichts zu löschen
if (total === 0) {
emitToUser(userId, 'purge-progress', { jobId, stage: 'nichts zu löschen', done: 0, total: 0, progress: 100, filesDeleted: 0 });
console.log(`✅ [purge:done] job=${jobId} deleted=0 files=0`);
return res.json({ dryRun: false, deletedCount: 0, filesDeleted: 0 });
}
let filesDeleted = 0;
let done = 0;
const BATCH = 500;
const t0 = Date.now();
const ping = (stage, overrideProgress) => {
const computed = total > 0 ? Math.round((done / total) * 98) : 1; // bis 98%
const progress = Math.max(1, Math.min(99, overrideProgress ?? computed));
emitToUser(userId, 'purge-progress', { jobId, stage, done, total, progress, filesDeleted });
};
ping('starte…', 1);
for (let i = 0; i < rows.length; i += BATCH) {
const slice = rows.slice(i, i + BATCH);
const ids = slice.map(r => r.id);
// DB: diesen Batch löschen
const delRes = await prisma.recognition.deleteMany({ where: { id: { in: ids } } });
done += delRes.count;
// Dateien: best-effort (außerhalb der DB-Transaktion)
if (alsoFiles) {
for (const r of slice) {
const deletedPaths = new Set();
// Bilder (Full + Plate)
for (const fname of [r.imageFile, r.plateFile]) {
if (!fname) continue;
try {
const abs = await findImageFile(fname);
if (abs && fs.existsSync(abs) && !deletedPaths.has(abs)) {
await fs.promises.unlink(abs);
filesDeleted += 1;
deletedPaths.add(abs);
}
} catch { /* ignore */ }
}
// XML-Sidecar (abgeleitet vom Bild-/Plate-Namen)
for (const candidate of [r.imageFile, r.plateFile]) {
if (!candidate) continue;
try {
const xmlAbs = await findSidecarXmlFromMediaFilename(candidate);
if (xmlAbs && fs.existsSync(xmlAbs) && !deletedPaths.has(xmlAbs)) {
await fs.promises.unlink(xmlAbs);
filesDeleted += 1;
deletedPaths.add(xmlAbs);
}
} catch { /* ignore */ }
}
}
}
// Fortschritt + Konsole
ping('lösche…');
if (i === 0 || i + BATCH >= rows.length || (i / BATCH) % 2 === 0) {
const pct = Math.round((done / total) * 100);
console.log(`… [purge:progress] job=${jobId} ${done}/${total} (${pct}%) files=${filesDeleted}`);
}
// Event-Loop freigeben, damit SSE sofort rausgeht
await new Promise(r => setImmediate(r));
}
ping('fertig', 99);
const ms = Date.now() - t0;
console.log(`✅ [purge:done] job=${jobId} deleted=${done} files=${filesDeleted} in ${ms}ms`);
return res.json({
dryRun: false,
deletedCount: done,
filesDeleted
});
} catch (err) {
console.error('❌ purge error:', err);
return res.status(500).json({ error: 'Löschen fehlgeschlagen' });
}
});
// === DELETE ===
app.delete('/api/notifications/:id', verifyToken, async (req, res) => {

View File

@ -1,11 +1,15 @@
// frontend\src\app\(protected)\admin\page.tsx
'use client';
import UserForm from '../../components/UserForm';
import UserTable from '../../components/UserTable';
import AdminPurgePanel from '../../components/AdminPurgePanel';
export default function Administration() {
return (
<div className="space-y-6">
<AdminPurgePanel />
<UserForm onUserCreated={() => {}} />
<UserTable />
</div>

View File

@ -5,6 +5,7 @@ import path from "path";
import ChecksumCopy from "@/app/components/ChecksumCopy";
import crypto from "crypto";
import { createReadStream } from "fs";
import Alert from "@/app/components/Alert";
export const runtime = "nodejs";
// optional: Seiten-Caching (Inhalte werden eh per mtime-Cache beschleunigt)
@ -83,14 +84,24 @@ async function withAutoChecksums(fw: Firmware): Promise<Firmware> {
/* ---------- Typen ---------- */
type AlertColor = 'dark' | 'secondary' | 'info' | 'success' | 'danger' | 'warning' | 'light';
type Notice = {
title?: string;
message: string;
type?: 'solid' | 'soft';
color?: AlertColor;
};
type Firmware = {
version: string;
date: string; // ISO-Datum
url: string;
releaseNotesUrl?: string; // <-- NEU: Link zur PDF
releaseNotesUrl?: string;
notes?: string; // (legacy, falls alte JSONs noch Text haben)
checksumSha256?: string;
checksumMD5?: string;
notice?: Notice;
};
type Doc = {
@ -98,11 +109,13 @@ type Doc = {
url: string;
lang?: string;
kind?: string;
notice?: Notice;
};
type DownloadsFile = {
firmwares: Firmware[];
documents: Doc[];
globalNotice?: Notice;
};
/* ---------- Utils ---------- */
@ -163,6 +176,15 @@ export default async function DownloadPage() {
</p>
</header>
{data.globalNotice && (
<Alert
title={data.globalNotice.title}
message={data.globalNotice.message}
type={data.globalNotice.type ?? 'soft'}
color={data.globalNotice.color ?? 'info'}
/>
)}
{/* 2-Spalten-Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
{/* Firmware (links) */}
@ -238,6 +260,14 @@ export default async function DownloadPage() {
<ChecksumCopy label="MD5" value={latest.checksumMD5} className="w-full" />
</div>
)}
{latest.notice && (
<Alert
title={latest.notice.title}
message={latest.notice.message}
type={latest.notice.type ?? 'soft'}
color={latest.notice.color ?? 'info'}
/>
)}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-neutral-300">Keine Firmware verfügbar.</p>
@ -249,15 +279,44 @@ export default async function DownloadPage() {
<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>
{/* statt <ul/divide-y> jetzt Karten wie bei "latest" */}
<div className="mt-3 space-y-3">
{older.map((fw) => (
<article
key={fw.version}
className="rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4"
>
{/* Kopfzeile: Version/Datum links, Download rechts wie bei latest */}
<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 {fw.version}
</div>
<div className="text-sm text-gray-600 dark:text-neutral-400">
{fmtDateDE(fw.date)}
</div>
</div>
<div className="mt-2 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>
</div>
{/* Release Notes Link wie bei latest */}
{(fw.releaseNotesUrl || fw.notes) && (
<div className="text-sm mt-1">
<p className="mt-3 text-sm">
<Link
href={fw.releaseNotesUrl ?? "#"}
prefetch={false}
@ -271,31 +330,29 @@ export default async function DownloadPage() {
</svg>
Release Notes (PDF)
</Link>
</div>
</p>
)}
{/* SHA/MD5 exakt wie bei latest (Spacing + w-full) */}
{(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 className="mt-2 space-y-1">
<ChecksumCopy label="SHA-256" value={fw.checksumSha256} className="w-full" />
<ChecksumCopy label="MD5" value={fw.checksumMD5} className="w-full" />
</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>
{/* Optionaler Hinweis */}
{fw.notice && (
<Alert
title={fw.notice.title}
message={fw.notice.message}
type={fw.notice.type ?? 'soft'}
color={fw.notice.color ?? 'info'}
/>
)}
</article>
))}
</ul>
</div>
</details>
)}
</section>
@ -322,6 +379,14 @@ export default async function DownloadPage() {
Download
</Link>
</div>
{doc.notice && (
<Alert
title={doc.notice.title}
message={doc.notice.message}
type={doc.notice.type ?? 'soft'}
color={doc.notice.color ?? 'info'}
/>
)}
</article>
))}
</div>

View File

@ -10,6 +10,7 @@ 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() {

View File

@ -0,0 +1,298 @@
// frontend\src\app\components\AdminPurgePanel.tsx
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Alert from '../components/Alert';
import { Button } from '../components/Button';
type PreviewResult = { dryRun: true; matchCount: number; sampleIds: number[]; camera: string|null; from: string|null; to: string|null };
type PurgeResult = { dryRun: false; deletedCount: number; filesDeleted: number };
type PurgeProgressEvt = { jobId: string; stage: string; done: number; total: number; progress: number; filesDeleted?: number };
export default function AdminPurgePanel() {
const [cameras, setCameras] = useState<string[]>([]);
const [camera, setCamera] = useState<string>('');
const [from, setFrom] = useState<string>('');
const [to, setTo] = useState<string>('');
const [alsoFiles, setAlsoFiles] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [result, setResult] = useState<PurgeResult | null>(null);
const [confirm, setConfirm] = useState<string>('');
// Progress (SSE)
const [jobId, setJobId] = useState<string | null>(null);
const [stage, setStage] = useState<string>('');
const [progress, setProgress] = useState<number>(0);
const [done, setDone] = useState<number>(0);
const [total, setTotal] = useState<number>(0);
const [filesDeleted, setFilesDeleted] = useState<number>(0);
const sseRef = useRef<EventSource | null>(null);
// Kameraliste laden
useEffect(() => {
fetch('/api/cameras', { credentials: 'include' })
.then(r => r.json())
.then(d => setCameras(d.cameras ?? []))
.catch(() => setCameras([]));
}, []);
const canDelete = useMemo(() => {
if (!preview) return false;
if (preview.matchCount <= 0) return false;
return confirm.trim().toUpperCase() === 'LÖSCHEN';
}, [preview, confirm]);
const attachSSE = (id: string) => {
// alte Verbindung schließen
if (sseRef.current) {
try { sseRef.current.close(); } catch {}
sseRef.current = null;
}
// neue Verbindung
const es = new EventSource('/api/recognitions/stream', { withCredentials: true } as EventSourceInit);
es.addEventListener('purge-progress', (evt: MessageEvent) => {
try {
const data = JSON.parse(evt.data) as PurgeProgressEvt;
if (data.jobId !== id) return;
setStage(data.stage || '');
setProgress(Math.max(0, Math.min(100, data.progress || 0)));
setDone(data.done ?? 0);
setTotal(data.total ?? 0);
setFilesDeleted(data.filesDeleted ?? 0);
} catch { /* ignore parse errors */ }
});
// optional: handle errors
es.onerror = () => { /* still keep it open; the server pings */ };
sseRef.current = es;
};
const detachSSE = () => {
if (sseRef.current) {
try { sseRef.current.close(); } catch {}
sseRef.current = null;
}
};
useEffect(() => {
return () => detachSSE(); // cleanup on unmount
}, []);
const doPreview = async () => {
setLoading(true);
setResult(null);
try {
const body = {
camera: camera || undefined,
from: from || undefined,
to: to || undefined,
dryRun: true
};
const r = await fetch('/api/admin/recognitions/purge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const json: PreviewResult | { error: string } = await r.json();
if ('error' in json) throw new Error(json.error);
setPreview(json as PreviewResult);
} catch (e) {
setPreview(null);
alert('Vorschau fehlgeschlagen: ' + e);
} finally {
setLoading(false);
}
};
const doDelete = async () => {
if (!preview) return;
setLoading(true);
setResult(null);
// Progress-UI resetten
const id = crypto.randomUUID();
setJobId(id);
setStage('starte…');
setProgress(1);
setDone(0);
setTotal(preview.matchCount);
setFilesDeleted(0);
attachSSE(id);
try {
const body = {
camera: camera || undefined,
from: from || undefined,
to: to || undefined,
dryRun: false,
alsoFiles,
clientJobId: id
};
const r = await fetch('/api/admin/recognitions/purge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const json: PurgeResult | { error: string } = await r.json();
if ('error' in json) throw new Error(json.error);
setResult(json as PurgeResult);
setPreview(null);
setConfirm('');
// finaler Balken
setStage('fertig');
setProgress(100);
} catch (e) {
alert('Löschen fehlgeschlagen: ' + e);
} finally {
setLoading(false);
// kleine Gnadenfrist, damit der letzte SSE-Stand sichtbar bleibt
setTimeout(() => {
detachSSE();
setJobId(null);
}, 800);
}
};
return (
<section className="rounded-lg border border-red-200 dark:border-red-900/40 bg-white dark:bg-neutral-900 p-4">
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400">Gefahrenbereich: Daten löschen</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
Als Admin kannst du Erkennungen nach Kamera und Zeitraum dauerhaft löschen.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Kamera */}
<label className="block">
<span className="text-sm text-gray-700 dark:text-neutral-300">Kamera (optional)</span>
<select
value={camera}
onChange={(e) => setCamera(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
>
<option value=""> Alle Kameras </option>
{cameras.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
{/* Zeitraum */}
<label className="block">
<span className="text-sm text-gray-700 dark:text-neutral-300">Von (inkl.)</span>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm text-gray-700 dark:text-neutral-300">Bis (inkl.)</span>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
/>
</label>
{/* Dateien */}
<label className="mt-2 inline-flex items-center gap-2">
<input
type="checkbox"
checked={alsoFiles}
onChange={(e) => setAlsoFiles(e.currentTarget.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-neutral-700"
/>
<span className="text-sm text-gray-700 dark:text-neutral-300">
Zugehörige Dateien (Bilder &amp; XML) ebenfalls löschen (best-effort)
</span>
</label>
</div>
<div className="mt-4 flex gap-2">
<Button size="small" variant="ghost" onClick={doPreview} disabled={loading}>
Vorschau
</Button>
</div>
{/* Preview */}
{preview && (
<Alert
type="soft"
color={preview.matchCount > 0 ? 'warning' : 'secondary'}
title="Vorschau"
message={
preview.matchCount > 0
? `${preview.matchCount.toLocaleString('de-DE')} Einträge würden gelöscht. ` +
(preview.camera ? `Kamera: ${preview.camera}. ` : 'Alle Kameras. ') +
(preview.from ? `Von: ${preview.from}. ` : '') +
(preview.to ? `Bis: ${preview.to}.` : '')
: 'Keine passenden Einträge gefunden.'
}
/>
)}
{/* Confirm + Delete */}
{preview && preview.matchCount > 0 && (
<div className="mt-4 space-y-3">
<Alert
type="soft"
color="danger"
title="Achtung"
message="Diese Aktion ist endgültig und kann nicht rückgängig gemacht werden."
/>
<label className="block">
<span className="text-sm text-gray-700 dark:text-neutral-300">
Tippe <b>LÖSCHEN</b> zur Bestätigung
</span>
<input
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
placeholder="LÖSCHEN"
/>
</label>
<div>
<Button size="small" variant="ghost" disabled={!canDelete || loading} onClick={doDelete}>
Endgültig löschen
</Button>
</div>
</div>
)}
{/* Fortschritt */}
{jobId && (
<div className="mt-6">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-neutral-400 mb-1">
<span>{stage || '…'}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 w-full rounded bg-gray-200 dark:bg-neutral-800 overflow-hidden">
<div
className="h-2 bg-blue-600 dark:bg-blue-500 transition-all"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-neutral-400">
{done.toLocaleString('de-DE')} / {total.toLocaleString('de-DE')} Einträge
{filesDeleted ? `${filesDeleted} Datei(en)` : ''}
</div>
</div>
)}
{/* Ergebnis */}
{result && (
<Alert
type="soft"
color="success"
title="Erledigt"
message={`${result.deletedCount.toLocaleString('de-DE')} Einträge gelöscht.` + (result.filesDeleted ? ` ${result.filesDeleted} Datei(en) entfernt.` : '')}
/>
)}
</section>
);
}

View File

@ -1,3 +1,5 @@
// frontend\src\app\components\Alert.tsx
'use client';
import clsx from 'clsx';

View File

@ -0,0 +1,62 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import Alert from './Alert';
type Props = {
href: string;
children: React.ReactNode; // Inhalt (Icon + Text)
className?: string; // Button-Klassen vom Aufrufer
download?: string | boolean; // durchreichen an <Link>
prefetch?: boolean;
alertTitle?: string;
alertMessage?: string;
alertType?: 'solid' | 'soft';
alertColor?: 'dark' | 'secondary' | 'info' | 'success' | 'danger' | 'warning' | 'light';
hideAfterMs?: number; // 0 = stehen lassen
};
export default function DownloadLinkWithAlert({
href,
children,
className,
download = true,
prefetch = false,
alertTitle = 'Download gestartet',
alertMessage = 'Dein Download sollte gleich beginnen. Falls nicht, Rechtsklick → „Link speichern unter…“.',
alertType = 'soft',
alertColor = 'info',
hideAfterMs = 6000,
}: Props) {
const [show, setShow] = useState(false);
useEffect(() => {
if (!show || hideAfterMs <= 0) return;
const t = setTimeout(() => setShow(false), hideAfterMs);
return () => clearTimeout(t);
}, [show, hideAfterMs]);
return (
<div className="inline-flex flex-col gap-2">
<Link
href={href}
prefetch={prefetch}
className={className}
download={download}
onClick={() => setShow(true)}
>
{children}
</Link>
{show && (
<Alert
title={alertTitle}
message={alertMessage}
type={alertType}
color={alertColor}
/>
)}
</div>
);
}