mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
499 lines
21 KiB
TypeScript
499 lines
21 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,
|
|
MAX_THUMBNAIL_BYTES,
|
|
THUMBNAIL_MAX_EDGE,
|
|
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;
|
|
}
|
|
|
|
const THUMBNAIL_SOURCE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
|
|
// Kleines JPEG (max. 480 px) statt des Originals als Vorschaubild. JPEG, weil Safari WebP per Canvas nicht zuverlässig erzeugt.
|
|
async function createThumbnail(file: File): Promise<Blob | null> {
|
|
if (!THUMBNAIL_SOURCE_TYPES.has(file.type)) return null;
|
|
try {
|
|
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
const scale = Math.min(1, THUMBNAIL_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return null;
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
bitmap.close();
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8));
|
|
return blob && blob.size <= MAX_THUMBNAIL_BYTES ? blob : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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[number] & { hasThumbnail: boolean })[] = [];
|
|
const failed: string[] = [];
|
|
setUpload({ done: 0, total: jobs.length });
|
|
|
|
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
|
|
const bucket = supabase.storage.from(DOCUMENTS_BUCKET);
|
|
const { error } = await bucket.upload(job.target.path, job.file, {
|
|
contentType: job.file.type || undefined,
|
|
upsert: false,
|
|
});
|
|
|
|
if (error) {
|
|
failed.push(job.file.name);
|
|
} else {
|
|
// Vorschaubild ist nur Komfort: schlägt es fehl, bleibt das Original trotzdem hochgeladen.
|
|
const thumbnail = await createThumbnail(job.file);
|
|
const thumbnailUploaded = thumbnail
|
|
? !(await bucket.upload(job.target.thumbnailPath, thumbnail, { contentType: "image/jpeg", upsert: false })).error
|
|
: false;
|
|
succeeded.push({ ...job, hasThumbnail: thumbnailUploaded });
|
|
}
|
|
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
|
|
});
|
|
|
|
if (succeeded.length > 0) {
|
|
const result = await finalizeUpload(
|
|
profileId,
|
|
folderId,
|
|
succeeded.map(({ target, file, hasThumbnail }) => ({
|
|
documentId: target.documentId,
|
|
path: target.path,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
hasThumbnail,
|
|
}))
|
|
);
|
|
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>
|
|
);
|
|
}
|