Files
archivmail/src/app/search/page.tsx
T
sysops b95c49daa5 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>
2026-03-17 10:12:09 +01:00

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>
);
}