118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import { useRef, type ChangeEvent } from "react";
|
||
import Box from "@mui/material/Box";
|
||
import Typography from "@mui/material/Typography";
|
||
import IconButton from "@mui/material/IconButton";
|
||
import Button from "./Button";
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} kB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
export interface FileUploadProps {
|
||
/** Selected files. Single-select mode keeps 0–1 items. */
|
||
files: File[];
|
||
onFilesChange: (files: File[]) => void;
|
||
/** e.g. "application/pdf" */
|
||
accept?: string;
|
||
/** Allow multiple files (appends on pick). Default false = single (replaces). */
|
||
multiple?: boolean;
|
||
/** Picker button label. */
|
||
buttonLabel?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
/**
|
||
* File picker: a button wrapping a hidden `<input type=file>`, with selected-file
|
||
* chips (name + size + remove). Single mode (default) replaces the selection;
|
||
* `multiple` appends. Value is always `File[]` so callers handle both uniformly.
|
||
* Validation (size/count/type beyond `accept`) stays with the caller.
|
||
*/
|
||
export default function FileUpload({
|
||
files,
|
||
onFilesChange,
|
||
accept,
|
||
multiple = false,
|
||
buttonLabel = "Vybrat soubor",
|
||
disabled,
|
||
}: FileUploadProps) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
const picked = Array.from(e.target.files ?? []);
|
||
if (picked.length === 0) return;
|
||
onFilesChange(multiple ? [...files, ...picked] : picked.slice(0, 1));
|
||
// Reset so picking the same file again (after removal) still fires onChange.
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
};
|
||
|
||
const remove = (idx: number) =>
|
||
onFilesChange(files.filter((_, i) => i !== idx));
|
||
|
||
return (
|
||
<Box>
|
||
<Button
|
||
component="label"
|
||
variant="outlined"
|
||
color="inherit"
|
||
size="small"
|
||
disabled={disabled}
|
||
>
|
||
{buttonLabel}
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
hidden
|
||
accept={accept}
|
||
multiple={multiple}
|
||
onChange={handleChange}
|
||
/>
|
||
</Button>
|
||
{files.length > 0 && (
|
||
<Box sx={{ mt: 1, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||
{files.map((f, i) => (
|
||
<Box
|
||
key={`${f.name}-${f.size}-${i}`}
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 0.5,
|
||
pl: 1,
|
||
pr: 0.25,
|
||
py: 0.25,
|
||
borderRadius: 1,
|
||
bgcolor: "action.hover",
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
maxWidth: 240,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{f.name}
|
||
</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
({formatSize(f.size)})
|
||
</Typography>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => remove(i)}
|
||
aria-label="Odebrat soubor"
|
||
disabled={disabled}
|
||
sx={{ p: 0.25, fontSize: "1rem", lineHeight: 1 }}
|
||
>
|
||
×
|
||
</IconButton>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|