Files
kit-ui/components/media/FileUpload.tsx
Sebastian Krüger b560dcbc8e refactor: streamline media converter UI and layout
- consolidated file upload and metadata display in single card
- replaced complex FormatSelector with simple shadcn Select component
- inlined all conversion options without toggle display
- restructured layout to 2-column grid matching pastel app pattern:
  - left column: upload and conversion options
  - right column: conversion results
- removed unused components (FileInfo, FormatSelector, ConversionOptionsPanel)
- cleaned up imports and simplified component hierarchy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 19:59:22 +01:00

348 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as React from 'react';
import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import type { ConversionFormat } from '@/types/media';
export interface FileUploadProps {
onFileSelect: (files: File[]) => void;
onFileRemove: (index: number) => void;
selectedFiles?: File[];
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
inputFormat?: ConversionFormat;
}
export function FileUpload({
onFileSelect,
onFileRemove,
selectedFiles = [],
accept,
maxSizeMB = 500,
disabled = false,
inputRef,
inputFormat,
}: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const [fileMetadata, setFileMetadata] = React.useState<Record<number, any>>({});
const localFileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localFileInputRef;
// Extract metadata for files
React.useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0 || !inputFormat) {
setFileMetadata({});
return;
}
const metadata: Record<number, any> = {};
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const baseMetadata = {
name: file.name,
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${(file.size / (1024 * 1024)).toFixed(2)} MB`,
type: inputFormat.name,
};
// Extract media-specific metadata
if (inputFormat.category === 'video' && file.type.startsWith('video/')) {
try {
const video = document.createElement('video');
video.preload = 'metadata';
const metadataPromise = new Promise<any>((resolve) => {
video.onloadedmetadata = () => {
const duration = video.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
resolve({
...baseMetadata,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
dimensions: `${video.videoWidth} × ${video.videoHeight}`,
});
URL.revokeObjectURL(video.src);
};
video.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(video.src);
};
});
video.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise;
} catch (error) {
metadata[i] = baseMetadata;
}
} else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) {
try {
const audio = document.createElement('audio');
audio.preload = 'metadata';
const metadataPromise = new Promise<any>((resolve) => {
audio.onloadedmetadata = () => {
const duration = audio.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
resolve({
...baseMetadata,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
});
URL.revokeObjectURL(audio.src);
};
audio.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(audio.src);
};
});
audio.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise;
} catch (error) {
metadata[i] = baseMetadata;
}
} else if (inputFormat.category === 'image' && file.type.startsWith('image/')) {
try {
const img = new Image();
const metadataPromise = new Promise<any>((resolve) => {
img.onload = () => {
resolve({
...baseMetadata,
dimensions: `${img.width} × ${img.height}`,
});
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(img.src);
};
});
img.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise;
} catch (error) {
metadata[i] = baseMetadata;
}
} else {
metadata[i] = baseMetadata;
}
}
setFileMetadata(metadata);
};
extractMetadata();
}, [selectedFiles, inputFormat]);
const getCategoryIcon = () => {
if (!inputFormat) return <File className="h-5 w-5 text-primary" />;
switch (inputFormat.category) {
case 'video':
return <FileVideo className="h-5 w-5 text-primary" />;
case 'audio':
return <FileAudio className="h-5 w-5 text-primary" />;
case 'image':
return <FileImage className="h-5 w-5 text-primary" />;
default:
return <File className="h-5 w-5 text-primary" />;
}
};
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) => {
const metadata = fileMetadata[index];
return (
<div key={`${file.name}-${index}`} className="border border-border rounded-lg p-4 bg-card">
<div className="flex items-start gap-3">
<div className="mt-0.5">{getCategoryIcon()}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground truncate" title={file.name}>
{file.name}
</p>
<Button
variant="ghost"
size="icon"
onClick={handleRemove(index)}
disabled={disabled}
className="ml-2 flex-shrink-0"
>
<X className="h-4 w-4" />
<span className="sr-only">Remove file</span>
</Button>
</div>
{metadata && (
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
{/* File Size */}
<div className="flex items-center gap-2 text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{metadata.size}</span>
</div>
{/* Type */}
<div className="flex items-center gap-2 text-muted-foreground">
<File className="h-3.5 w-3.5" />
<span>{metadata.type}</span>
</div>
{/* Duration (for video/audio) */}
{metadata.duration && (
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{metadata.duration}</span>
</div>
)}
{/* Dimensions */}
{metadata.dimensions && (
<div className="flex items-center gap-2 text-muted-foreground">
{inputFormat?.category === 'video' ? (
<Film className="h-3.5 w-3.5" />
) : (
<FileImage className="h-3.5 w-3.5" />
)}
<span>{metadata.dimensions}</span>
</div>
)}
</div>
)}
</div>
</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>
);
}