Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/PhasedFileDropzone.tsx
2026-07-02 12:06:17 +03:00

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