mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:11:13 -05:00
199 lines
5.3 KiB
TypeScript
199 lines
5.3 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
|
import { Upload, Image, X } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
|
|
interface DragDropZoneProps {
|
|
onFilesAdded: (files: File[]) => void;
|
|
maxFiles?: number;
|
|
maxSizeMB?: number;
|
|
allowedFileTypes?: string[];
|
|
disabled?: boolean;
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export function DragDropZone({
|
|
onFilesAdded,
|
|
maxFiles = 10,
|
|
maxSizeMB = 25,
|
|
allowedFileTypes = ['image/*'],
|
|
disabled = false,
|
|
className = '',
|
|
children,
|
|
}: DragDropZoneProps) {
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
const validateFiles = useCallback((files: FileList) => {
|
|
const validFiles: File[] = [];
|
|
const errors: string[] = [];
|
|
|
|
Array.from(files).forEach((file) => {
|
|
// Check file type
|
|
const isValidType = allowedFileTypes.some(type => {
|
|
if (type === 'image/*') {
|
|
return file.type.startsWith('image/');
|
|
}
|
|
return file.type === type;
|
|
});
|
|
|
|
if (!isValidType) {
|
|
errors.push(`${file.name}: Invalid file type`);
|
|
return;
|
|
}
|
|
|
|
// Check file size
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
errors.push(`${file.name}: File too large (max ${maxSizeMB}MB)`);
|
|
return;
|
|
}
|
|
|
|
validFiles.push(file);
|
|
});
|
|
|
|
// Check total file count
|
|
if (validFiles.length > maxFiles) {
|
|
errors.push(`Too many files. Maximum ${maxFiles} files allowed.`);
|
|
return { validFiles: validFiles.slice(0, maxFiles), errors };
|
|
}
|
|
|
|
return { validFiles, errors };
|
|
}, [allowedFileTypes, maxSizeMB, maxFiles]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!disabled) {
|
|
setIsDragOver(true);
|
|
}
|
|
}, [disabled]);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
|
|
if (disabled) return;
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (files.length === 0) return;
|
|
|
|
const { validFiles, errors } = validateFiles(files);
|
|
|
|
if (errors.length > 0) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'File Validation Error',
|
|
description: errors.join(', '),
|
|
});
|
|
}
|
|
|
|
if (validFiles.length > 0) {
|
|
onFilesAdded(validFiles);
|
|
}
|
|
}, [disabled, validateFiles, onFilesAdded, toast]);
|
|
|
|
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (disabled) return;
|
|
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const { validFiles, errors } = validateFiles(files);
|
|
|
|
if (errors.length > 0) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'File Validation Error',
|
|
description: errors.join(', '),
|
|
});
|
|
}
|
|
|
|
if (validFiles.length > 0) {
|
|
onFilesAdded(validFiles);
|
|
}
|
|
|
|
// Reset input
|
|
e.target.value = '';
|
|
}, [disabled, validateFiles, onFilesAdded, toast]);
|
|
|
|
if (children) {
|
|
return (
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
"relative transition-all duration-200",
|
|
isDragOver && !disabled && "ring-2 ring-accent ring-offset-2",
|
|
className
|
|
)}
|
|
>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept={allowedFileTypes.join(',')}
|
|
onChange={handleFileInput}
|
|
disabled={disabled}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
|
/>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
"relative border-2 border-dashed rounded-lg transition-all duration-200 p-8",
|
|
isDragOver && !disabled
|
|
? "border-accent bg-accent/5 scale-[1.02]"
|
|
: "border-border hover:border-accent/50",
|
|
disabled && "opacity-50 cursor-not-allowed",
|
|
!disabled && "cursor-pointer hover:bg-muted/50",
|
|
className
|
|
)}
|
|
>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept={allowedFileTypes.join(',')}
|
|
onChange={handleFileInput}
|
|
disabled={disabled}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
/>
|
|
|
|
<div className="text-center space-y-4">
|
|
<div className="mx-auto w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center">
|
|
{isDragOver ? (
|
|
<Upload className="w-8 h-8 text-accent animate-bounce" />
|
|
) : (
|
|
<Image className="w-8 h-8 text-accent" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-lg font-medium">
|
|
{isDragOver ? 'Drop files here' : 'Drag & drop photos here'}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
or click to browse files
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Max {maxFiles} files, {maxSizeMB}MB each
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |