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:
@@ -114,6 +114,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/admin/upload", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpload)))
|
s.mux.HandleFunc("POST /api/admin/upload", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpload)))
|
||||||
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadProgress)))
|
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadProgress)))
|
||||||
|
|
||||||
|
// Upload routes (all authenticated users)
|
||||||
|
s.mux.HandleFunc("POST /api/upload", s.authMiddleware(s.handleUpload))
|
||||||
|
s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.authMiddleware(s.handleUploadProgress))
|
||||||
|
|
||||||
// IMAP routes (accessible to all authenticated users)
|
// IMAP routes (accessible to all authenticated users)
|
||||||
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
|
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
|
||||||
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
|
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
|
||||||
|
|||||||
+145
-2
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
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 { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -68,6 +69,14 @@ export default function SearchPage() {
|
|||||||
const [exportAttachments, setExportAttachments] = useState(false);
|
const [exportAttachments, setExportAttachments] = useState(false);
|
||||||
const [exporting, setExporting] = 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
|
// Clear selection when results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(new Set());
|
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 totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
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}>
|
<Button type="submit" disabled={searching}>
|
||||||
{searching ? "Suche..." : "Suchen"}
|
{searching ? "Suche..." : "Suchen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)}>
|
||||||
|
Importieren
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -484,3 +484,22 @@ export async function uploadMailFiles(files: File[]): Promise<{ job_id: string }
|
|||||||
export async function getUploadProgress(jobID: string): Promise<UploadJob> {
|
export async function getUploadProgress(jobID: string): Promise<UploadJob> {
|
||||||
return request<UploadJob>(`/api/admin/upload/${jobID}/progress`);
|
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