since3-portal/modules/shared/ui/components/documents-browser.tsx
2026-09-29 16:37:48 +02:00

459 lines
19 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { zipSync } from "fflate";
import { DownloadIcon, LoaderCircleIcon, Trash2Icon, UploadIcon } from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { createClient } from "@/lib/supabase/client";
import { deleteDocument, finalizeUpload, getDocumentUrls, prepareUpload } from "@/modules/shared/actions/documents";
import {
DOCUMENTS_BUCKET,
MAX_FILES_PER_UPLOAD,
MAX_FILE_BYTES,
fileKind,
previewKind,
formatFileSize,
type DocumentItem,
} from "@/modules/shared/schemas/documents";
import { FileKindIcon } from "./file-icon";
const PARALLEL_UPLOADS = 3;
const TEXT_PREVIEW_LIMIT = 200_000;
function triggerDownload(href: string, fileName?: string) {
const link = document.createElement("a");
link.href = href;
if (fileName) link.download = fileName;
link.rel = "noopener";
link.click();
}
function uniqueName(name: string, used: Set<string>) {
if (!used.has(name)) {
used.add(name);
return name;
}
const dot = name.lastIndexOf(".");
const base = dot > 0 ? name.slice(0, dot) : name;
const extension = dot > 0 ? name.slice(dot) : "";
let counter = 2;
while (used.has(`${base} (${counter})${extension}`)) counter++;
const next = `${base} (${counter})${extension}`;
used.add(next);
return next;
}
async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
let index = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (index < items.length) {
const item = items[index++];
await worker(item);
}
})
);
}
function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
const kind = previewKind(doc.mimeType);
const [text, setText] = useState<string | null>(null);
useEffect(() => {
if (kind !== "text") return;
let cancelled = false;
fetch(url)
.then((response) => response.text())
.then((content) => {
if (!cancelled) {
setText(content.length > TEXT_PREVIEW_LIMIT ? `${content.slice(0, TEXT_PREVIEW_LIMIT)}\n…` : content);
}
})
.catch(() => !cancelled && setText("Vorschau konnte nicht geladen werden."));
return () => {
cancelled = true;
};
}, [kind, url]);
switch (kind) {
case "image":
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
return <img src={url} alt={doc.name} className="mx-auto max-h-[70vh] w-auto rounded-md object-contain" />;
case "pdf":
return <iframe src={url} title={doc.name} className="h-[70vh] w-full rounded-md border border-border" />;
case "video":
return <video src={url} controls className="max-h-[70vh] w-full rounded-md" />;
case "audio":
return <audio src={url} controls className="w-full" />;
case "text":
return (
<pre className="max-h-[70vh] overflow-auto rounded-md border border-border bg-surface p-4 text-small whitespace-pre-wrap break-words">
{text ?? "Wird geladen..."}
</pre>
);
default:
return null;
}
}
function PreviewDialog({
doc,
profileId,
isAdmin,
onClose,
onDownload,
}: {
doc: DocumentItem | null;
profileId: string;
isAdmin: boolean;
onClose: () => void;
onDownload: (ids: string[]) => void;
}) {
const [url, setUrl] = useState<{ id: string; value: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!doc) return;
let cancelled = false;
getDocumentUrls(profileId, [doc.id], "preview").then((result) => {
if (cancelled) return;
if ("error" in result) setError(result.error);
else setUrl({ id: doc.id, value: result.files[0].url });
});
return () => {
cancelled = true;
};
}, [doc, profileId]);
function handleOpenChange(open: boolean) {
if (open) return;
setUrl(null);
setError(null);
setConfirmDelete(false);
onClose();
}
async function handleDelete() {
if (!doc) return;
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setDeleting(true);
const result = await deleteDocument(profileId, doc.id);
setDeleting(false);
if (result && "error" in result) {
setError(result.error);
return;
}
handleOpenChange(false);
}
const currentUrl = doc && url?.id === doc.id ? url.value : null;
return (
<Dialog open={doc !== null} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[95vh] overflow-y-auto sm:max-w-4xl">
{doc && (
<>
<DialogHeader>
<DialogTitle className="truncate pr-8">{doc.name}</DialogTitle>
<p className="text-small text-muted-foreground">{formatFileSize(doc.sizeBytes)}</p>
</DialogHeader>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : currentUrl ? (
<PreviewContent doc={doc} url={currentUrl} />
) : (
<div className="flex h-60 items-center justify-center text-muted-foreground">
<LoaderCircleIcon className="size-6 animate-spin" />
</div>
)}
<DialogFooter className="gap-2">
{isAdmin && (
<Button type="button" variant={confirmDelete ? "destructive" : "outline"} onClick={handleDelete} disabled={deleting}>
<Trash2Icon className="size-4" />
{deleting ? "Wird gelöscht..." : confirmDelete ? "Wirklich löschen?" : "Löschen"}
</Button>
)}
<Button type="button" onClick={() => onDownload([doc.id])}>
<DownloadIcon className="size-4" /> Herunterladen
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
export function DocumentsBrowser({
profileId,
folderId,
isAdmin,
documents,
}: {
profileId: string;
folderId: string;
isAdmin: boolean;
documents: DocumentItem[];
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<{ type: "error" | "info"; text: string } | null>(null);
const [dragActive, setDragActive] = useState(false);
const [preview, setPreview] = useState<DocumentItem | null>(null);
const visibleSelection = documents.filter((doc) => selected.has(doc.id)).map((doc) => doc.id);
const busy = upload !== null || downloading;
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleFiles(fileList: FileList | File[]) {
const files = Array.from(fileList);
if (files.length === 0 || busy) return;
setMessage(null);
if (files.length > MAX_FILES_PER_UPLOAD) {
setMessage({ type: "error", text: `Bitte höchstens ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` });
return;
}
const tooLarge = files.find((file) => file.size > MAX_FILE_BYTES);
if (tooLarge) {
setMessage({ type: "error", text: `${tooLarge.name} ist größer als ${formatFileSize(MAX_FILE_BYTES)}.` });
return;
}
const prepared = await prepareUpload(
profileId,
folderId,
files.map((file) => ({ name: file.name, size: file.size, type: file.type }))
);
if ("error" in prepared) {
setMessage({ type: "error", text: prepared.error });
return;
}
const supabase = createClient();
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
const succeeded: typeof jobs = [];
const failed: string[] = [];
setUpload({ done: 0, total: jobs.length });
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
const { error } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false });
if (error) failed.push(job.file.name);
else succeeded.push(job);
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
});
if (succeeded.length > 0) {
const result = await finalizeUpload(
profileId,
folderId,
succeeded.map(({ target, file }) => ({
documentId: target.documentId,
path: target.path,
name: file.name,
size: file.size,
type: file.type,
}))
);
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
}
setUpload(null);
if (inputRef.current) inputRef.current.value = "";
setMessage(
failed.length > 0
? { type: "error", text: `Nicht hochgeladen: ${failed.join(", ")}` }
: { type: "info", text: `${succeeded.length} ${succeeded.length === 1 ? "Datei" : "Dateien"} hochgeladen.` }
);
}
async function handleDownload(ids: string[]) {
if (ids.length === 0 || downloading) return;
setDownloading(true);
setMessage(null);
try {
if (ids.length === 1) {
const result = await getDocumentUrls(profileId, ids, "download");
if ("error" in result) throw new Error(result.error);
triggerDownload(result.files[0].url);
return;
}
const result = await getDocumentUrls(profileId, ids, "preview");
if ("error" in result) throw new Error(result.error);
const used = new Set<string>();
const entries: Record<string, Uint8Array> = {};
for (const file of result.files) {
const response = await fetch(file.url);
if (!response.ok) throw new Error(`${file.name} konnte nicht geladen werden.`);
entries[uniqueName(file.name, used)] = new Uint8Array(await response.arrayBuffer());
}
const blobUrl = URL.createObjectURL(new Blob([zipSync(entries, { level: 0 })], { type: "application/zip" }));
triggerDownload(blobUrl, "Dokumente.zip");
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
setMessage({ type: "error", text: error instanceof Error ? error.message : "Download fehlgeschlagen." });
} finally {
setDownloading(false);
}
}
function openDocument(doc: DocumentItem) {
if (previewKind(doc.mimeType)) setPreview(doc);
else handleDownload([doc.id]);
}
return (
<div
className="relative flex flex-col gap-4"
onDragOver={(event) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
setDragActive(true);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
handleFiles(event.dataTransfer.files);
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-small text-muted-foreground">
{documents.length > 0 && (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="size-4 cursor-pointer accent-primary"
checked={visibleSelection.length === documents.length}
onChange={() =>
setSelected(visibleSelection.length === documents.length ? new Set() : new Set(documents.map((doc) => doc.id)))
}
/>
{visibleSelection.length > 0 ? `${visibleSelection.length} ausgewählt` : "Alle auswählen"}
</label>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => handleDownload(visibleSelection)} disabled={visibleSelection.length === 0 || busy}>
{downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"}
</Button>
<Button onClick={() => inputRef.current?.click()} disabled={busy}>
{upload ? <LoaderCircleIcon className="size-4 animate-spin" /> : <UploadIcon className="size-4" />}
{upload ? `Hochladen ${upload.done}/${upload.total}` : "Hochladen"}
</Button>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(event) => event.target.files && handleFiles(event.target.files)}
/>
</div>
</div>
{message && (
<p className={cn("text-sm", message.type === "error" ? "text-destructive" : "text-muted-foreground")} aria-live="polite">
{message.text}
</p>
)}
{documents.length === 0 ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-surface/50 p-12 text-center text-muted-foreground transition-colors hover:bg-surface"
>
<UploadIcon className="size-8" />
<span>Noch keine Dateien. Hierher ziehen oder klicken zum Hochladen.</span>
</button>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{documents.map((doc) => {
const kind = fileKind(doc.name, doc.mimeType);
const isSelected = selected.has(doc.id);
return (
<li
key={doc.id}
className={cn(
"group relative flex flex-col overflow-hidden rounded-lg border bg-background transition-colors",
isSelected ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-ink-300"
)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggle(doc.id)}
aria-label={`${doc.name} auswählen`}
className={cn(
"absolute top-2 left-2 z-10 size-4 cursor-pointer accent-primary transition-opacity",
isSelected ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
)}
/>
<button
type="button"
onClick={() => openDocument(doc)}
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={doc.name}
>
<div className="flex aspect-[4/3] items-center justify-center bg-surface">
{doc.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />
) : (
<FileKindIcon kind={kind} className="size-12 stroke-[1.25]" />
)}
</div>
<div className="flex flex-col gap-0.5 p-2.5">
<span className="truncate text-small font-medium">{doc.name}</span>
<span className="text-label text-muted-foreground">{formatFileSize(doc.sizeBytes)}</span>
</div>
</button>
</li>
);
})}
</ul>
)}
{dragActive && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-background/80 font-medium">
Dateien hier ablegen
</div>
)}
<PreviewDialog
doc={preview}
profileId={profileId}
isAdmin={isAdmin}
onClose={() => setPreview(null)}
onDownload={handleDownload}
/>
</div>
);
}