Keyboard shortcuts: - **Ctrl/Cmd + O**: Open file dialog - **Ctrl/Cmd + Enter**: Start conversion - **Ctrl/Cmd + S**: Download results (ZIP if multiple) - **Ctrl/Cmd + R**: Reset converter - **Ctrl/Cmd + /**: Show keyboard shortcuts help - **?**: Show keyboard shortcuts help - **Escape**: Close shortcuts modal Implementation: - Created useKeyboardShortcuts custom hook - Platform-aware keyboard handling (Cmd on Mac, Ctrl elsewhere) - KeyboardShortcutsModal component with beautiful UI - Floating keyboard icon button (bottom-right) - Visual shortcut display with kbd tags - Context-aware shortcut execution (respects disabled states) - Prevents default browser behavior for shortcuts User experience: - Floating help button always accessible - Clean modal with shortcut descriptions - Platform-specific key symbols (⌘ on Mac) - Shortcuts disabled when modal is open - Clear visual feedback for shortcuts - Non-intrusive button placement Features: - Smart ref forwarding to FileUpload component - Conditional shortcut execution based on app state - Professional kbd styling for key combinations - Responsive modal with backdrop - Smooth animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
4.8 KiB
TypeScript
179 lines
4.8 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Upload, X } from 'lucide-react';
|
|
import { cn } from '@/lib/utils/cn';
|
|
import { formatFileSize } from '@/lib/utils/fileUtils';
|
|
import { Button } from '@/components/ui/Button';
|
|
|
|
export interface FileUploadProps {
|
|
onFileSelect: (files: File[]) => void;
|
|
onFileRemove: (index: number) => void;
|
|
selectedFiles?: File[];
|
|
accept?: string;
|
|
maxSizeMB?: number;
|
|
disabled?: boolean;
|
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
|
}
|
|
|
|
export function FileUpload({
|
|
onFileSelect,
|
|
onFileRemove,
|
|
selectedFiles = [],
|
|
accept,
|
|
maxSizeMB = 500,
|
|
disabled = false,
|
|
inputRef,
|
|
}: FileUploadProps) {
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const fileInputRef = inputRef || React.useRef<HTMLInputElement>(null);
|
|
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!disabled) {
|
|
setIsDragging(true);
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
|
|
if (disabled) return;
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length > 0) {
|
|
handleFiles(files);
|
|
}
|
|
};
|
|
|
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
handleFiles(files);
|
|
}
|
|
};
|
|
|
|
const handleFiles = (files: File[]) => {
|
|
// Check file sizes
|
|
const maxBytes = maxSizeMB * 1024 * 1024;
|
|
const validFiles = files.filter(file => {
|
|
if (file.size > maxBytes) {
|
|
alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (validFiles.length > 0) {
|
|
onFileSelect(validFiles);
|
|
}
|
|
|
|
// Reset input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleClick = () => {
|
|
if (!disabled) {
|
|
fileInputRef.current?.click();
|
|
}
|
|
};
|
|
|
|
const handleRemove = (index: number) => (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onFileRemove(index);
|
|
};
|
|
|
|
return (
|
|
<div className="w-full space-y-3">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
accept={accept}
|
|
onChange={handleFileInput}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{selectedFiles.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{selectedFiles.map((file, index) => (
|
|
<div key={`${file.name}-${index}`} className="border-2 border-border rounded-lg p-4 bg-card">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-foreground truncate">{file.name}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{formatFileSize(file.size)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRemove(index)}
|
|
disabled={disabled}
|
|
className="ml-4"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Remove file</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add more files button */}
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClick}
|
|
disabled={disabled}
|
|
className="w-full"
|
|
>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
Add More Files
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={handleClick}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
'border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors',
|
|
'hover:border-primary hover:bg-primary/5',
|
|
{
|
|
'border-primary bg-primary/10': isDragging,
|
|
'border-border bg-background': !isDragging,
|
|
'opacity-50 cursor-not-allowed': disabled,
|
|
}
|
|
)}
|
|
>
|
|
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
|
<p className="text-sm font-medium text-foreground mb-1">
|
|
Drop your files here or click to browse
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Maximum file size: {maxSizeMB}MB per file
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|