b95c49daa5
- 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>
557 lines
21 KiB
TypeScript
557 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
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,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export default function SearchPage() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [fromFilter, setFromFilter] = useState("");
|
|
const [toFilter, setToFilter] = useState("");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [sort, setSort] = useState("date_desc");
|
|
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
|
|
|
|
const [results, setResults] = useState<SearchHit[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [searching, setSearching] = useState(false);
|
|
const [searched, setSearched] = useState(false);
|
|
|
|
// Selection state
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [exportOpen, setExportOpen] = useState(false);
|
|
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());
|
|
}, [results]);
|
|
|
|
const doSearch = useCallback(
|
|
async (p: number) => {
|
|
setSearching(true);
|
|
try {
|
|
const res = await searchEmails({
|
|
q: query || undefined,
|
|
from: fromFilter || undefined,
|
|
to: toFilter || undefined,
|
|
date_from: dateFrom || undefined,
|
|
date_to: dateTo || undefined,
|
|
sort: sort !== "date_desc" ? sort : undefined,
|
|
has_attachment: hasAttachment,
|
|
page: p,
|
|
page_size: PAGE_SIZE,
|
|
});
|
|
setResults(res.hits || []);
|
|
setTotal(res.total);
|
|
setPage(p);
|
|
setSearched(true);
|
|
} catch {
|
|
setResults([]);
|
|
setTotal(0);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
},
|
|
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
|
|
);
|
|
|
|
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
setSearching(true);
|
|
searchEmails({ page: 1, page_size: PAGE_SIZE })
|
|
.then((res) => {
|
|
setResults(res.hits || []);
|
|
setTotal(res.total);
|
|
setPage(1);
|
|
setSearched(true);
|
|
})
|
|
.catch(() => {
|
|
setResults([]);
|
|
setTotal(0);
|
|
})
|
|
.finally(() => setSearching(false));
|
|
}, [user]);
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
doSearch(1);
|
|
}
|
|
|
|
async function handleExportZIP() {
|
|
setExporting(true);
|
|
try {
|
|
const { blob } = await exportMailsZIP(Array.from(selected), exportAttachments);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "archivmail-export.zip";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
setExportOpen(false);
|
|
setSelected(new Set());
|
|
} catch (e) {
|
|
alert(`Export fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
|
<main className="mx-auto max-w-7xl px-4 py-6">
|
|
{(authLoading || !user) && (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
)}
|
|
{!authLoading && user && (<>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Volltextsuche..."
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="flex-1"
|
|
aria-label="Suchbegriff"
|
|
/>
|
|
<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">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="from-filter" className="text-xs">
|
|
Von (Absender)
|
|
</Label>
|
|
<Input
|
|
id="from-filter"
|
|
placeholder="absender@example.com"
|
|
value={fromFilter}
|
|
onChange={(e) => setFromFilter(e.target.value)}
|
|
aria-label="Absender filtern"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="to-filter" className="text-xs">
|
|
An (Empfänger)
|
|
</Label>
|
|
<Input
|
|
id="to-filter"
|
|
placeholder="empfaenger@example.com"
|
|
value={toFilter}
|
|
onChange={(e) => setToFilter(e.target.value)}
|
|
aria-label="Empfänger filtern"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="date-from" className="text-xs">
|
|
Datum von
|
|
</Label>
|
|
<Input
|
|
id="date-from"
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => setDateFrom(e.target.value)}
|
|
aria-label="Datum von"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="date-to" className="text-xs">
|
|
Datum bis
|
|
</Label>
|
|
<Input
|
|
id="date-to"
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => setDateTo(e.target.value)}
|
|
aria-label="Datum bis"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
|
|
<Select value={sort} onValueChange={setSort}>
|
|
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="date_desc">Datum (neu → alt)</SelectItem>
|
|
<SelectItem value="date_asc">Datum (alt → neu)</SelectItem>
|
|
<SelectItem value="relevance">Relevanz</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="attach-toggle"
|
|
checked={hasAttachment === true}
|
|
onCheckedChange={(checked) =>
|
|
setHasAttachment(checked ? true : undefined)
|
|
}
|
|
/>
|
|
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
|
|
Nur mit Anhang
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="mt-6">
|
|
{searching ? (
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
) : searched && results.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Keine E-Mails gefunden.
|
|
</CardContent>
|
|
</Card>
|
|
) : results.length > 0 ? (
|
|
<>
|
|
<div className="mb-2 text-sm text-muted-foreground">
|
|
{query || fromFilter || toFilter || dateFrom || dateTo
|
|
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
|
|
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
|
</div>
|
|
|
|
{selected.size > 0 && (
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">{selected.size} ausgewählt</span>
|
|
<Button size="sm" onClick={() => setExportOpen(true)}>Als ZIP exportieren</Button>
|
|
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) setSelected(new Set(results.map((h) => h.id)));
|
|
else setSelected(new Set());
|
|
}}
|
|
aria-label="Alle auswählen"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-32">Datum</TableHead>
|
|
<TableHead className="w-56">Von</TableHead>
|
|
<TableHead>Betreff</TableHead>
|
|
<TableHead className="w-48">An</TableHead>
|
|
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
|
|
<TableHead className="w-20 text-right">Größe</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((hit) => (
|
|
<TableRow
|
|
key={hit.id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => router.push(`/mail/${hit.id}`)}
|
|
role="link"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") router.push(`/mail/${hit.id}`);
|
|
}}
|
|
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={selected.has(hit.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(hit.id);
|
|
else next.delete(hit.id);
|
|
return next;
|
|
});
|
|
}}
|
|
aria-label="Mail auswählen"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
|
{hit.date
|
|
? new Date(hit.date).toLocaleDateString("de-DE")
|
|
: "-"}
|
|
</TableCell>
|
|
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
|
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
|
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
|
<TableCell className="text-center text-sm">
|
|
{hit.has_attachments ? "📎" : ""}
|
|
</TableCell>
|
|
<TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
|
|
{hit.size ? formatBytes(hit.size) : ""}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => doSearch(page - 1)}
|
|
>
|
|
Zurueck
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Seite {page} von {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => doSearch(page + 1)}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>E-Mails exportieren</DialogTitle>
|
|
<DialogDescription>
|
|
{selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex items-center gap-3 py-2">
|
|
<Switch
|
|
id="attachments"
|
|
checked={exportAttachments}
|
|
onCheckedChange={setExportAttachments}
|
|
/>
|
|
<Label htmlFor="attachments">Anhänge einschließen</Label>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setExportOpen(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button onClick={handleExportZIP} disabled={exporting}>
|
|
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|