201 lines
6.5 KiB
JavaScript
201 lines
6.5 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import Dropzone from 'react-dropzone';
|
|
import { Spinner } from 'react-bootstrap';
|
|
import { TbCloudUpload } from 'react-icons/tb';
|
|
import clsx from 'clsx';
|
|
import FileExtensionWithPreview from '@/components/FileExtensionWithPreview';
|
|
import {
|
|
FILE_EXTENSIONS,
|
|
IMAGE_EXTENSIONS,
|
|
IMPORT_EXTENSIONS,
|
|
S3_MIMES,
|
|
uploadFileToS3,
|
|
} from '@/helpers/s3Upload';
|
|
|
|
const MAX_SIZE = 500 * 1024 * 1024;
|
|
|
|
function extensionsFor(field) {
|
|
if (Array.isArray(field?.allowedExtensions) && field.allowedExtensions.length) {
|
|
return field.allowedExtensions;
|
|
}
|
|
if (field?.type === 'file' && field.accept === 'import') return IMPORT_EXTENSIONS;
|
|
if (field?.type === 'image' || field?.type === 'logo') return IMAGE_EXTENSIONS;
|
|
return FILE_EXTENSIONS;
|
|
}
|
|
|
|
function acceptFromExtensions(exts) {
|
|
return exts.reduce((accept, ext) => {
|
|
const mime = S3_MIMES[ext];
|
|
if (!mime) return accept;
|
|
const dotted = `.${ext}`;
|
|
if (!accept[mime]) accept[mime] = [];
|
|
if (!accept[mime].includes(dotted)) accept[mime].push(dotted);
|
|
return accept;
|
|
}, {});
|
|
}
|
|
|
|
function isImagePath(value, type) {
|
|
if (type?.startsWith('image/')) return true;
|
|
return /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(value || '');
|
|
}
|
|
|
|
function fileNameFromUrl(url) {
|
|
try {
|
|
return decodeURIComponent(new URL(url).pathname.split('/').pop() || 'file');
|
|
} catch {
|
|
return String(url || '').split('/').pop() || 'file';
|
|
}
|
|
}
|
|
|
|
function rejectMessage(rejected, exts) {
|
|
const code = rejected?.[0]?.errors?.[0]?.code;
|
|
if (code === 'file-too-large') return 'The file size is too big (500MB max).';
|
|
if (code === 'file-invalid-type') return `The file is not allowed (${exts.join(', ')} only).`;
|
|
return rejected?.[0]?.errors?.[0]?.message || 'File was rejected';
|
|
}
|
|
|
|
export default function S3FileField({
|
|
field,
|
|
value = '',
|
|
onChange,
|
|
moduleName,
|
|
required = false,
|
|
onUploadingChange,
|
|
}) {
|
|
const [uploading, setUploading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [localFile, setLocalFile] = useState(null);
|
|
const previewUrlRef = useRef('');
|
|
const onChangeRef = useRef(onChange);
|
|
const uploadingRef = useRef(onUploadingChange);
|
|
|
|
onChangeRef.current = onChange;
|
|
uploadingRef.current = onUploadingChange;
|
|
|
|
const exts = extensionsFor(field);
|
|
const accept = acceptFromExtensions(exts);
|
|
|
|
const clearPreview = () => {
|
|
if (previewUrlRef.current) {
|
|
URL.revokeObjectURL(previewUrlRef.current);
|
|
previewUrlRef.current = '';
|
|
}
|
|
};
|
|
|
|
useEffect(() => () => clearPreview(), []);
|
|
|
|
const onDrop = useCallback(
|
|
async (accepted, rejected) => {
|
|
if (rejected?.length) {
|
|
setError(rejectMessage(rejected, exts));
|
|
return;
|
|
}
|
|
const file = accepted[0];
|
|
if (!file) return;
|
|
clearPreview();
|
|
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : '';
|
|
previewUrlRef.current = preview;
|
|
setLocalFile({ name: file.name, size: file.size, type: file.type, preview });
|
|
setError('');
|
|
setUploading(true);
|
|
uploadingRef.current?.(true);
|
|
try {
|
|
const url = await uploadFileToS3(file, { moduleName: field?.uploadModule || moduleName });
|
|
onChangeRef.current?.(url);
|
|
} catch (err) {
|
|
setError(err.message || 'Upload failed');
|
|
onChangeRef.current?.('');
|
|
setLocalFile(null);
|
|
clearPreview();
|
|
} finally {
|
|
setUploading(false);
|
|
uploadingRef.current?.(false);
|
|
}
|
|
},
|
|
[exts, field?.uploadModule, moduleName],
|
|
);
|
|
|
|
const onRemove = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (uploading) return;
|
|
clearPreview();
|
|
setLocalFile(null);
|
|
setError('');
|
|
onChange?.('');
|
|
};
|
|
|
|
const previewSrc = localFile?.preview || (isImagePath(value, localFile?.type) ? value : '');
|
|
const displayName = localFile?.name || (value ? fileNameFromUrl(value) : '');
|
|
const hasFile = Boolean(localFile || value);
|
|
const extension = (displayName.split('.').pop() || '').toLowerCase();
|
|
|
|
return (
|
|
<div className="s3-file-field-wrap">
|
|
<input type="hidden" value={value || ''} required={required && !uploading} readOnly />
|
|
<Dropzone
|
|
onDrop={onDrop}
|
|
accept={accept}
|
|
maxSize={MAX_SIZE}
|
|
maxFiles={1}
|
|
multiple={false}
|
|
disabled={uploading}
|
|
>
|
|
{({ getRootProps, getInputProps, isDragActive }) => (
|
|
<div
|
|
className={clsx(
|
|
's3-file-field',
|
|
hasFile && 'has-preview',
|
|
isDragActive && 'is-dragover',
|
|
uploading && 'is-uploading',
|
|
error && 'has-error',
|
|
)}
|
|
{...getRootProps()}
|
|
>
|
|
<input {...getInputProps()} />
|
|
{hasFile ? (
|
|
<>
|
|
<div className="s3-file-field-preview">
|
|
<div className="s3-file-field-render">
|
|
{previewSrc ? (
|
|
<img src={previewSrc} alt="" />
|
|
) : (
|
|
<FileExtensionWithPreview className="s3-file-field-icon" width={72} height={72} extension={extension} />
|
|
)}
|
|
</div>
|
|
<div className="s3-file-field-infos">
|
|
<div className="s3-file-field-infos-inner">
|
|
<p className="s3-file-field-filename" title={displayName}>
|
|
{displayName}
|
|
</p>
|
|
<p className="s3-file-field-hint">
|
|
{uploading ? 'Uploading...' : 'Drag and drop or click to replace'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{uploading ? (
|
|
<span className="s3-file-field-loader">
|
|
<Spinner animation="border" size="sm" />
|
|
</span>
|
|
) : (
|
|
<button type="button" className="s3-file-field-remove" onClick={onRemove}>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="s3-file-field-message">
|
|
<TbCloudUpload className="s3-file-field-upload-icon" />
|
|
<p className="mb-0">Drag and drop a file here or click</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
<p className="text-muted text-center mt-2 mb-0">500MB Max File size</p>
|
|
{error ? <div className="text-danger fs-12 mt-1">{error}</div> : null}
|
|
</div>
|
|
);
|
|
}
|