feat(PROJ-2): EML/MBOX-Upload für alle Benutzer zugänglich

- Backend: neue Routen POST /api/upload + GET /api/upload/{jobID}/progress (nur Auth, kein Admin)
- api.ts: uploadMailFilesUser + getUploadProgressUser für /api/upload
- search/page.tsx: Importieren-Button + Upload-Dialog mit Drag-and-Drop, Fortschrittsanzeige und automatischer Suchlisten-Aktualisierung nach Import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 10:12:09 +01:00
parent 1e677659b9
commit b95c49daa5
3 changed files with 168 additions and 2 deletions
+145 -2
View File
@@ -1,14 +1,15 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, exportMailsZIP, type SearchHit } from "@/lib/api";
import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
@@ -68,6 +69,14 @@ export default function SearchPage() {
const [exportAttachments, setExportAttachments] = useState(false);
const [exporting, setExporting] = useState(false);
// Upload state
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadDragging, setUploadDragging] = useState(false);
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
const [uploadError, setUploadError] = useState("");
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Clear selection when results change
useEffect(() => {
setSelected(new Set());
@@ -144,6 +153,54 @@ export default function SearchPage() {
}
}
async function handleUploadFiles(files: FileList | File[]) {
const allowed = Array.from(files).filter((f) => {
const n = f.name.toLowerCase();
return n.endsWith(".eml") || n.endsWith(".mbox");
});
if (allowed.length === 0) {
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
return;
}
setUploadError("");
setUploadJob(null);
setUploadLoading(true);
try {
const { job_id } = await uploadMailFilesUser(allowed);
uploadPollRef.current = setInterval(async () => {
try {
const job = await getUploadProgressUser(job_id);
setUploadJob(job);
if (job.status !== "running") {
clearInterval(uploadPollRef.current!);
uploadPollRef.current = null;
setUploadLoading(false);
// Refresh search results after successful import
if (job.status === "done") doSearch(1);
}
} catch {
clearInterval(uploadPollRef.current!);
uploadPollRef.current = null;
setUploadLoading(false);
}
}, 1500);
} catch (e) {
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
setUploadLoading(false);
}
}
function handleUploadClose() {
if (uploadPollRef.current) {
clearInterval(uploadPollRef.current);
uploadPollRef.current = null;
}
setUploadOpen(false);
setUploadJob(null);
setUploadError("");
setUploadLoading(false);
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
@@ -171,6 +228,9 @@ export default function SearchPage() {
<Button type="submit" disabled={searching}>
{searching ? "Suche..." : "Suchen"}
</Button>
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)}>
Importieren
</Button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -406,6 +466,89 @@ export default function SearchPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload Dialog */}
<Dialog open={uploadOpen} onOpenChange={(open) => { if (!open) handleUploadClose(); else setUploadOpen(true); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails importieren</DialogTitle>
<DialogDescription>
EML- oder MBOX-Dateien in das Archiv hochladen
</DialogDescription>
</DialogHeader>
{!uploadJob && (
<div
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
}`}
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
onDragLeave={() => setUploadDragging(false)}
onDrop={(e) => {
e.preventDefault();
setUploadDragging(false);
if (e.dataTransfer.files.length > 0) handleUploadFiles(e.dataTransfer.files);
}}
>
<p className="text-sm text-muted-foreground mb-3">
Dateien hierher ziehen oder auswählen
</p>
<label>
<input
type="file"
accept=".eml,.mbox"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) handleUploadFiles(e.target.files); }}
/>
<Button type="button" variant="outline" size="sm" asChild>
<span>Dateien auswählen</span>
</Button>
</label>
<p className="mt-2 text-xs text-muted-foreground">.eml · .mbox</p>
</div>
)}
{uploadError && (
<p className="text-sm text-destructive">{uploadError}</p>
)}
{uploadJob && (
<div className="space-y-3">
<Progress
value={uploadJob.total > 0 ? Math.round(((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100) : 0}
/>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-semibold text-green-600">{uploadJob.imported}</div>
<div className="text-xs text-muted-foreground">Importiert</div>
</div>
<div>
<div className="font-semibold text-yellow-600">{uploadJob.skipped}</div>
<div className="text-xs text-muted-foreground">Duplikate</div>
</div>
<div>
<div className="font-semibold text-red-600">{uploadJob.errors}</div>
<div className="text-xs text-muted-foreground">Fehler</div>
</div>
</div>
{uploadJob.status === "running" && (
<p className="text-xs text-center text-muted-foreground">
Verarbeite {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total}
</p>
)}
{uploadJob.status === "done" && (
<p className="text-xs text-center text-green-600 font-medium">Abgeschlossen</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleUploadClose} disabled={uploadLoading && uploadJob?.status === "running"}>
{uploadJob?.status === "done" ? "Schließen" : "Abbrechen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>)}
</main>
</div>