Rapport_React/src/components/S3FileField.jsx

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