Files
Aurora-SpotiRip/components/FileDropzone.tsx
T
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

163 lines
4.3 KiB
TypeScript

import React, { useCallback, useState } from 'react';
import { Upload, X } from 'lucide-react';
import { Button } from './Button';
import styles from './FileDropzone.module.css';
export interface FileDropzoneProps {
className?: string;
accept?: string;
maxFiles?: number;
maxSize?: number;
onFilesSelected?: (files: File[]) => void;
disabled?: boolean;
icon?: React.ReactNode;
title?: React.ReactNode;
subtitle?: React.ReactNode;
}
export const FileDropzone: React.FC<FileDropzoneProps> = ({
className = '',
accept,
maxFiles = 1,
maxSize,
onFilesSelected,
disabled = false,
icon = <Upload size={48} />,
title = "Click to upload or drag and drop",
subtitle,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
}
}, [disabled]);
const validateFiles = useCallback((fileList: FileList | null): File[] => {
if (!fileList) return [];
const files = Array.from(fileList);
if (files.length > maxFiles) {
setError(`Maximum ${maxFiles} file${maxFiles === 1 ? '' : 's'} allowed`);
return [];
}
if (accept) {
const acceptedTypes = accept.split(',').map(type => type.trim());
const invalidFiles = files.filter(file =>
!acceptedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type.match(new RegExp(type.replace('*', '.*')));
})
);
if (invalidFiles.length > 0) {
setError(`Invalid file type. Accepted: ${accept}`);
return [];
}
}
if (maxSize) {
const oversizedFiles = files.filter(file => file.size > maxSize);
if (oversizedFiles.length > 0) {
setError(`File size exceeds ${Math.round(maxSize/1024/1024)}MB limit`);
return [];
}
}
setError(null);
return files;
}, [accept, maxFiles, maxSize]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
setIsDragging(false);
const files = validateFiles(e.dataTransfer.files);
if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
}, [disabled, validateFiles, onFilesSelected]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = validateFiles(e.target.files);
if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
e.target.value = '';
}, [disabled, validateFiles, onFilesSelected]);
const clearError = useCallback(() => {
setError(null);
}, []);
const defaultSubtitle = (accept || maxFiles > 1) && (
<>
{accept && `Accepted types: ${accept}`}
{maxFiles > 1 && ` (Up to ${maxFiles} files)`}
</>
);
return (
<div className={`${styles.container} ${className ?? ""}`}>
<label
className={`
${styles.dropzone}
${isDragging ? styles.dragging : ''}
${disabled ? styles.disabled : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
onChange={handleFileInput}
accept={accept}
multiple={maxFiles > 1}
disabled={disabled}
aria-label="File upload"
/>
<span className={styles.icon}>{icon}</span>
<span className={styles.title}>{title}</span>
{(subtitle || defaultSubtitle) && (
<span className={styles.subtitle}>{subtitle || defaultSubtitle}</span>
)}
</label>
{error && (
<div className={styles.error} role="alert">
<span>{error}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={clearError}
aria-label="Clear error"
>
<X size={16} />
</Button>
</div>
)}
</div>
);
};