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:
+145
-2
@@ -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>
|
||||
|
||||
@@ -484,3 +484,22 @@ export async function uploadMailFiles(files: File[]): Promise<{ job_id: string }
|
||||
export async function getUploadProgress(jobID: string): Promise<UploadJob> {
|
||||
return request<UploadJob>(`/api/admin/upload/${jobID}/progress`);
|
||||
}
|
||||
|
||||
export async function uploadMailFilesUser(files: File[]): Promise<{ job_id: string }> {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f);
|
||||
const res = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(body || `Upload failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getUploadProgressUser(jobID: string): Promise<UploadJob> {
|
||||
return request<UploadJob>(`/api/upload/${jobID}/progress`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user