193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useState } from 'react';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import { Upload, X, FileImage, FileVideo, FileAudio, File as FileIcon } from 'lucide-react';
|
|
import { clsx } from 'clsx';
|
|
|
|
interface FileUploadProps {
|
|
onUpload?: (file: File) => void;
|
|
onUploadMultiple?: (files: File[]) => void;
|
|
accept?: Record<string, string[]>;
|
|
maxSize?: number;
|
|
label?: string;
|
|
currentFile?: File | null;
|
|
onClear?: () => void;
|
|
multiple?: boolean;
|
|
}
|
|
|
|
const fileIcons: Record<string, any> = {
|
|
image: FileImage,
|
|
video: FileVideo,
|
|
audio: FileAudio,
|
|
};
|
|
|
|
export default function FileUpload({
|
|
onUpload,
|
|
onUploadMultiple,
|
|
accept,
|
|
maxSize = 100 * 1024 * 1024, // 100MB default
|
|
label = 'Upload a file',
|
|
currentFile,
|
|
onClear,
|
|
multiple = false,
|
|
}: FileUploadProps) {
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const onDrop = useCallback(
|
|
(acceptedFiles: File[], rejectedFiles: any[]) => {
|
|
setError(null);
|
|
|
|
if (rejectedFiles.length > 0) {
|
|
const rejection = rejectedFiles[0];
|
|
if (rejection.errors[0]?.code === 'file-too-large') {
|
|
setError(`File too large. Max size is ${Math.round(maxSize / 1024 / 1024)}MB`);
|
|
} else if (rejection.errors[0]?.code === 'file-invalid-type') {
|
|
setError('Invalid file type');
|
|
} else {
|
|
setError('File rejected');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (acceptedFiles.length > 0) {
|
|
if (onUploadMultiple) {
|
|
onUploadMultiple(acceptedFiles);
|
|
} else if (onUpload) {
|
|
onUpload(acceptedFiles[0]);
|
|
}
|
|
}
|
|
},
|
|
[onUpload, onUploadMultiple, maxSize]
|
|
);
|
|
|
|
// Helper to validate mime type against accept prop
|
|
const checkType = (file: File) => {
|
|
if (!accept) return true;
|
|
const fileType = file.type;
|
|
const fileExt = `.${file.name.split('.').pop()?.toLowerCase()}`;
|
|
|
|
// Iterate over accept keys (image/*, etc)
|
|
for (const [mime, exts] of Object.entries(accept)) {
|
|
if (mime === '*/*') return true;
|
|
// Check wildcard
|
|
if (mime.endsWith('/*')) {
|
|
const baseMime = mime.split('/')[0];
|
|
if (fileType.startsWith(baseMime + '/')) return true;
|
|
}
|
|
// Check exact mime
|
|
if (fileType === mime) return true;
|
|
// Check extensions
|
|
if (exts && exts.some(ext => ext.toLowerCase() === fileExt)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept,
|
|
maxSize,
|
|
multiple: multiple || !!onUploadMultiple,
|
|
});
|
|
|
|
const getFileIcon = (file: File) => {
|
|
const type = file.type.split('/')[0];
|
|
const Icon = fileIcons[type] || FileIcon;
|
|
return <Icon className="w-8 h-8 text-forge-yellow" />;
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
};
|
|
|
|
if (currentFile) {
|
|
return (
|
|
<div className="bg-forge-dark rounded-xl p-4 border border-gray-700">
|
|
<div className="flex items-center gap-4">
|
|
{getFileIcon(currentFile)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-white font-medium truncate">{currentFile.name}</p>
|
|
<p className="text-sm text-gray-500">{formatFileSize(currentFile.size)}</p>
|
|
</div>
|
|
{onClear && (
|
|
<button
|
|
onClick={onClear}
|
|
className="p-2 text-gray-400 hover:text-red-400 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onDragOver={(e) => {
|
|
// Needed to allow dropping non-files (like our JSON asset)
|
|
if (e.dataTransfer.types.includes('application/json')) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}}
|
|
onDrop={async (e) => {
|
|
// Check if we dropped our custom asset
|
|
const jsonData = e.dataTransfer.getData('application/json');
|
|
if (jsonData) {
|
|
try {
|
|
const data = JSON.parse(jsonData);
|
|
if (data.type === 'forge-asset' && data.url) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const res = await fetch(data.url);
|
|
const blob = await res.blob();
|
|
const file = new File([blob], data.filename, { type: blob.type });
|
|
|
|
// Validate type
|
|
if (!checkType(file)) {
|
|
setError('Invalid file type for this tool');
|
|
return;
|
|
}
|
|
|
|
if (onUploadMultiple) {
|
|
onUploadMultiple([file]);
|
|
} else if (onUpload) {
|
|
onUpload(file);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Not our JSON, ignore
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
{...getRootProps()}
|
|
data-file-drop-zone="true"
|
|
className={clsx(
|
|
'upload-zone',
|
|
isDragActive && 'active'
|
|
)}
|
|
>
|
|
<input {...getInputProps()} />
|
|
<Upload className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
|
<p className="text-white font-medium mb-2">{label}</p>
|
|
<p className="text-gray-500 text-sm">
|
|
Drag and drop or <span className="text-forge-yellow">browse</span>
|
|
</p>
|
|
{accept && (
|
|
<p className="text-gray-600 text-xs mt-2">
|
|
Accepted: {Object.keys(accept).join(', ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<p className="mt-2 text-sm text-red-400">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|