mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ActionIcon,
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
UnstyledButton,
|
|
} from "@mantine/core";
|
|
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
|
|
import { isViewable } from "@edr/ui-common";
|
|
|
|
export interface PhasedFileDropzoneProps {
|
|
label: string;
|
|
description?: string;
|
|
value: File | null;
|
|
onChange: (file: File | null) => void;
|
|
accept?: string;
|
|
replaceMode?: boolean;
|
|
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
|
}
|
|
|
|
function isImageFile(file: File): boolean {
|
|
if (file.type.startsWith("image/")) return true;
|
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
|
|
}
|
|
|
|
export function PhasedFileDropzone({
|
|
label,
|
|
description,
|
|
value,
|
|
onChange,
|
|
accept = "application/pdf,image/*",
|
|
replaceMode = false,
|
|
onPreview,
|
|
disabled = false,
|
|
}: PhasedFileDropzoneProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const previewUrl = useMemo(
|
|
() => (value ? URL.createObjectURL(value) : null),
|
|
[value],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
};
|
|
}, [previewUrl]);
|
|
|
|
const pickFile = (file: File | null) => {
|
|
if (disabled) return;
|
|
onChange(file);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (disabled) return;
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) pickFile(file);
|
|
};
|
|
|
|
if (value && previewUrl) {
|
|
const canPreview = onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
|
|
const image = isImageFile(value);
|
|
|
|
return (
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>
|
|
{label}
|
|
</Text>
|
|
<Box
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
border: "1px solid var(--mantine-color-edr-green-4)",
|
|
background:
|
|
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" wrap="nowrap" align="center">
|
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
{image ? (
|
|
<UnstyledButton
|
|
onClick={() =>
|
|
canPreview && onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
|
|
}
|
|
style={{
|
|
width: 52,
|
|
height: 52,
|
|
flexShrink: 0,
|
|
borderRadius: 10,
|
|
overflow: "hidden",
|
|
border: "1px solid var(--mantine-color-gray-3)",
|
|
cursor: canPreview ? "pointer" : "default",
|
|
}}
|
|
>
|
|
<img
|
|
src={previewUrl}
|
|
alt=""
|
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
/>
|
|
</UnstyledButton>
|
|
) : (
|
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
|
<FileText size={22} />
|
|
</ThemeIcon>
|
|
)}
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
|
Ready to upload
|
|
</Text>
|
|
<Text size="sm" fw={600} truncate>
|
|
{value.name}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{formatBytes(value.size)}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<Group gap={6} wrap="nowrap">
|
|
{canPreview ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="default"
|
|
leftSection={<Eye size={13} />}
|
|
onClick={() =>
|
|
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
|
|
}
|
|
>
|
|
Preview
|
|
</Button>
|
|
) : null}
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
radius="md"
|
|
aria-label="Remove file"
|
|
onClick={() => pickFile(null)}
|
|
disabled={disabled}
|
|
>
|
|
<Trash2 size={15} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>
|
|
{label}
|
|
</Text>
|
|
{description ? (
|
|
<Text size="xs" c="dimmed">
|
|
{description}
|
|
</Text>
|
|
) : null}
|
|
<Box
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (!disabled) setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => !disabled && inputRef.current?.click()}
|
|
style={{
|
|
borderRadius: 12,
|
|
border: `2px dashed ${
|
|
dragOver
|
|
? "var(--mantine-color-edr-green-5)"
|
|
: "var(--mantine-color-gray-4)"
|
|
}`,
|
|
background: dragOver
|
|
? "var(--mantine-color-edr-green-0)"
|
|
: "var(--mantine-color-gray-0)",
|
|
padding: "28px 20px",
|
|
textAlign: "center",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.6 : 1,
|
|
transition: "border-color 120ms ease, background 120ms ease",
|
|
}}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
hidden
|
|
disabled={disabled}
|
|
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<Stack gap={8} align="center">
|
|
<ThemeIcon
|
|
variant="light"
|
|
color={dragOver ? "edr-green" : "gray"}
|
|
radius="xl"
|
|
size={48}
|
|
>
|
|
<UploadCloud size={24} />
|
|
</ThemeIcon>
|
|
<Box>
|
|
<Text size="sm" fw={600}>
|
|
{dragOver
|
|
? "Drop to upload"
|
|
: replaceMode
|
|
? "Drag & drop to replace"
|
|
: "Drag & drop your file here"}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
or <span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>browse</span>{" "}
|
|
— PDF or image
|
|
</Text>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export interface PhasedMultiFileDropzoneProps {
|
|
label: string;
|
|
description?: string;
|
|
value: File[];
|
|
onChange: (files: File[]) => void;
|
|
accept?: string;
|
|
replaceMode?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function PhasedMultiFileDropzone({
|
|
label,
|
|
description,
|
|
value,
|
|
onChange,
|
|
accept = "application/pdf,image/*",
|
|
replaceMode = false,
|
|
disabled = false,
|
|
}: PhasedMultiFileDropzoneProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const addFiles = (incoming: FileList | File[]) => {
|
|
if (disabled) return;
|
|
const next = [...value];
|
|
for (const file of Array.from(incoming)) {
|
|
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
|
|
next.push(file);
|
|
}
|
|
}
|
|
onChange(next);
|
|
};
|
|
|
|
const removeAt = (index: number) => {
|
|
if (disabled) return;
|
|
onChange(value.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
|
};
|
|
|
|
return (
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>
|
|
{label}
|
|
</Text>
|
|
{description ? (
|
|
<Text size="xs" c="dimmed">
|
|
{description}
|
|
</Text>
|
|
) : null}
|
|
|
|
{value.length > 0 ? (
|
|
<Stack gap={8}>
|
|
{value.map((file, index) => (
|
|
<Box
|
|
key={`${file.name}-${file.size}-${index}`}
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
border: "1px solid var(--mantine-color-edr-green-4)",
|
|
background:
|
|
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" wrap="nowrap" align="center">
|
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
|
|
<FileText size={18} />
|
|
</ThemeIcon>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
|
Ready to upload
|
|
</Text>
|
|
<Text size="sm" fw={600} truncate>
|
|
{file.name}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{formatBytes(file.size)}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
radius="md"
|
|
aria-label="Remove file"
|
|
onClick={() => removeAt(index)}
|
|
disabled={disabled}
|
|
>
|
|
<Trash2 size={15} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
) : null}
|
|
|
|
<Box
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (!disabled) setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => !disabled && inputRef.current?.click()}
|
|
style={{
|
|
borderRadius: 12,
|
|
border: `2px dashed ${
|
|
dragOver
|
|
? "var(--mantine-color-edr-green-5)"
|
|
: "var(--mantine-color-gray-4)"
|
|
}`,
|
|
background: dragOver
|
|
? "var(--mantine-color-edr-green-0)"
|
|
: "var(--mantine-color-gray-0)",
|
|
padding: "28px 20px",
|
|
textAlign: "center",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.6 : 1,
|
|
transition: "border-color 120ms ease, background 120ms ease",
|
|
}}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
multiple
|
|
hidden
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
if (e.target.files?.length) addFiles(e.target.files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<Stack gap={8} align="center">
|
|
<ThemeIcon
|
|
variant="light"
|
|
color={dragOver ? "edr-green" : "gray"}
|
|
radius="xl"
|
|
size={48}
|
|
>
|
|
<UploadCloud size={24} />
|
|
</ThemeIcon>
|
|
<Box>
|
|
<Text size="sm" fw={600}>
|
|
{dragOver
|
|
? "Drop to add files"
|
|
: replaceMode
|
|
? "Drag & drop to replace declaration files"
|
|
: "Drag & drop declaration files here"}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
or{" "}
|
|
<span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>
|
|
browse
|
|
</span>{" "}
|
|
— select one or more PDF or image files
|
|
</Text>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|