coder/site/src/components/FileUpload/FileUpload.tsx

187 lines
4.1 KiB
TypeScript

import { css, type Interpolation, type Theme } from "@emotion/react";
import UploadIcon from "@mui/icons-material/CloudUploadOutlined";
import RemoveIcon from "@mui/icons-material/DeleteOutline";
import FileIcon from "@mui/icons-material/FolderOutlined";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import { type FC, type DragEvent, useRef, type ReactNode } from "react";
import { Stack } from "components/Stack/Stack";
import { useClickable } from "hooks/useClickable";
export interface FileUploadProps {
isUploading: boolean;
onUpload: (file: File) => void;
onRemove?: () => void;
file?: File;
removeLabel: string;
title: string;
description?: ReactNode;
extensions?: string[];
}
export const FileUpload: FC<FileUploadProps> = ({
isUploading,
onUpload,
onRemove,
file,
removeLabel,
title,
description,
extensions,
}) => {
const fileDrop = useFileDrop(onUpload, extensions);
const inputRef = useRef<HTMLInputElement>(null);
const clickable = useClickable<HTMLDivElement>(
() => inputRef.current?.click(),
);
if (!isUploading && file) {
return (
<Stack
css={styles.file}
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Stack direction="row" alignItems="center">
<FileIcon />
<span>{file.name}</span>
</Stack>
<IconButton title={removeLabel} size="small" onClick={onRemove}>
<RemoveIcon />
</IconButton>
</Stack>
);
}
return (
<>
<div
data-testid="drop-zone"
css={[styles.root, isUploading && styles.disabled]}
{...clickable}
{...fileDrop}
>
<Stack alignItems="center" spacing={1}>
{isUploading ? (
<CircularProgress size={32} />
) : (
<UploadIcon css={styles.icon} />
)}
<Stack alignItems="center" spacing={0.5}>
<span css={styles.title}>{title}</span>
<span css={styles.description}>{description}</span>
</Stack>
</Stack>
</div>
<input
type="file"
data-testid="file-upload"
ref={inputRef}
css={styles.input}
accept={extensions?.map((ext) => `.${ext}`).join(",")}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) {
onUpload(file);
}
}}
/>
</>
);
};
const useFileDrop = (
callback: (file: File) => void,
extensions?: string[],
): {
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
} => {
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0] as File | undefined;
if (!file) {
return;
}
if (!extensions) {
callback(file);
return;
}
const extension = file.name.split(".").pop();
if (!extension) {
throw new Error(`File has no extension to compare with ${extensions}`);
}
if (extensions.includes(extension)) {
callback(file);
}
};
return {
onDragOver,
onDrop,
};
};
const styles = {
root: (theme) => css`
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 2px dashed ${theme.palette.divider};
padding: 48px;
cursor: pointer;
&:hover {
background-color: ${theme.palette.background.paper};
}
`,
disabled: {
pointerEvents: "none",
opacity: 0.75,
},
icon: {
fontSize: 64,
},
title: {
fontSize: 16,
lineHeight: "1",
},
description: (theme) => ({
color: theme.palette.text.secondary,
textAlign: "center",
maxWidth: 400,
fontSize: 14,
lineHeight: "1.5",
marginTop: 4,
}),
input: {
display: "none",
},
file: (theme) => ({
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
padding: 16,
background: theme.palette.background.paper,
}),
} satisfies Record<string, Interpolation<Theme>>;