2026-02-25 10:06:50 +01:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import * as React from 'react';
|
2026-02-25 19:59:22 +01:00
|
|
|
|
import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react';
|
2026-02-25 10:06:50 +01:00
|
|
|
|
import { cn } from '@/lib/utils/cn';
|
2026-02-25 19:59:22 +01:00
|
|
|
|
import type { ConversionFormat } from '@/types/media';
|
2026-02-25 10:06:50 +01:00
|
|
|
|
|
|
|
|
|
|
export interface FileUploadProps {
|
|
|
|
|
|
onFileSelect: (files: File[]) => void;
|
|
|
|
|
|
onFileRemove: (index: number) => void;
|
|
|
|
|
|
selectedFiles?: File[];
|
|
|
|
|
|
accept?: string;
|
|
|
|
|
|
maxSizeMB?: number;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
2026-02-25 19:59:22 +01:00
|
|
|
|
inputFormat?: ConversionFormat;
|
2026-02-25 10:06:50 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 08:22:17 +01:00
|
|
|
|
function CategoryIcon({ format, className }: { format?: ConversionFormat; className?: string }) {
|
|
|
|
|
|
const cls = cn('text-primary', className);
|
|
|
|
|
|
if (!format) return <File className={cls} />;
|
|
|
|
|
|
switch (format.category) {
|
|
|
|
|
|
case 'video': return <FileVideo className={cls} />;
|
|
|
|
|
|
case 'audio': return <FileAudio className={cls} />;
|
|
|
|
|
|
case 'image': return <FileImage className={cls} />;
|
|
|
|
|
|
default: return <File className={cls} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 10:06:50 +01:00
|
|
|
|
export function FileUpload({
|
|
|
|
|
|
onFileSelect,
|
|
|
|
|
|
onFileRemove,
|
|
|
|
|
|
selectedFiles = [],
|
|
|
|
|
|
accept,
|
|
|
|
|
|
maxSizeMB = 500,
|
|
|
|
|
|
disabled = false,
|
|
|
|
|
|
inputRef,
|
2026-02-25 19:59:22 +01:00
|
|
|
|
inputFormat,
|
2026-02-25 10:06:50 +01:00
|
|
|
|
}: FileUploadProps) {
|
|
|
|
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const [fileMetadata, setFileMetadata] = React.useState<Record<number, Record<string, string>>>({});
|
|
|
|
|
|
const localRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const fileInputRef = inputRef || localRef;
|
2026-02-25 19:59:22 +01:00
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const extract = async () => {
|
|
|
|
|
|
if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; }
|
|
|
|
|
|
const out: Record<number, Record<string, string>> = {};
|
2026-02-25 19:59:22 +01:00
|
|
|
|
for (let i = 0; i < selectedFiles.length; i++) {
|
|
|
|
|
|
const file = selectedFiles[i];
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const base = {
|
|
|
|
|
|
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` : `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
|
2026-02-25 19:59:22 +01:00
|
|
|
|
type: inputFormat.name,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (inputFormat.category === 'video' && file.type.startsWith('video/')) {
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const video = document.createElement('video');
|
|
|
|
|
|
video.preload = 'metadata';
|
|
|
|
|
|
out[i] = await new Promise((res) => {
|
|
|
|
|
|
video.onloadedmetadata = () => {
|
|
|
|
|
|
const d = video.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
|
|
|
|
|
|
res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}`, dimensions: `${video.videoWidth}×${video.videoHeight}` });
|
|
|
|
|
|
URL.revokeObjectURL(video.src);
|
|
|
|
|
|
};
|
|
|
|
|
|
video.onerror = () => { res(base); URL.revokeObjectURL(video.src); };
|
2026-02-25 19:59:22 +01:00
|
|
|
|
video.src = URL.createObjectURL(file);
|
2026-03-01 08:22:17 +01:00
|
|
|
|
});
|
2026-02-25 19:59:22 +01:00
|
|
|
|
} else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) {
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const audio = document.createElement('audio');
|
|
|
|
|
|
audio.preload = 'metadata';
|
|
|
|
|
|
out[i] = await new Promise((res) => {
|
|
|
|
|
|
audio.onloadedmetadata = () => {
|
|
|
|
|
|
const d = audio.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
|
|
|
|
|
|
res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}` });
|
|
|
|
|
|
URL.revokeObjectURL(audio.src);
|
|
|
|
|
|
};
|
|
|
|
|
|
audio.onerror = () => { res(base); URL.revokeObjectURL(audio.src); };
|
2026-02-25 19:59:22 +01:00
|
|
|
|
audio.src = URL.createObjectURL(file);
|
2026-03-01 08:22:17 +01:00
|
|
|
|
});
|
2026-02-25 19:59:22 +01:00
|
|
|
|
} else if (inputFormat.category === 'image' && file.type.startsWith('image/')) {
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const img = new Image();
|
|
|
|
|
|
out[i] = await new Promise((res) => {
|
|
|
|
|
|
img.onload = () => { res({ ...base, dimensions: `${img.width}×${img.height}` }); URL.revokeObjectURL(img.src); };
|
|
|
|
|
|
img.onerror = () => { res(base); URL.revokeObjectURL(img.src); };
|
2026-02-25 19:59:22 +01:00
|
|
|
|
img.src = URL.createObjectURL(file);
|
2026-03-01 08:22:17 +01:00
|
|
|
|
});
|
2026-02-25 19:59:22 +01:00
|
|
|
|
} else {
|
2026-03-01 08:22:17 +01:00
|
|
|
|
out[i] = base;
|
2026-02-25 19:59:22 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 08:22:17 +01:00
|
|
|
|
setFileMetadata(out);
|
2026-02-25 19:59:22 +01:00
|
|
|
|
};
|
2026-03-01 08:22:17 +01:00
|
|
|
|
extract();
|
2026-02-25 19:59:22 +01:00
|
|
|
|
}, [selectedFiles, inputFormat]);
|
|
|
|
|
|
|
2026-02-25 10:06:50 +01:00
|
|
|
|
const handleFiles = (files: File[]) => {
|
|
|
|
|
|
const maxBytes = maxSizeMB * 1024 * 1024;
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const valid = files.filter((f) => {
|
|
|
|
|
|
if (f.size > maxBytes) { alert(`${f.name} exceeds ${maxSizeMB}MB limit.`); return false; }
|
2026-02-25 10:06:50 +01:00
|
|
|
|
return true;
|
|
|
|
|
|
});
|
2026-03-01 08:22:17 +01:00
|
|
|
|
if (valid.length > 0) onFileSelect(valid);
|
|
|
|
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
2026-02-25 10:06:50 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
2026-02-25 10:06:50 +01:00
|
|
|
|
e.stopPropagation();
|
2026-03-01 08:22:17 +01:00
|
|
|
|
setIsDragging(false);
|
|
|
|
|
|
if (!disabled) handleFiles(Array.from(e.dataTransfer.files));
|
2026-02-25 10:06:50 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-01 08:22:17 +01:00
|
|
|
|
const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); };
|
|
|
|
|
|
|
2026-02-25 10:06:50 +01:00
|
|
|
|
return (
|
2026-03-01 08:22:17 +01:00
|
|
|
|
<div className="w-full flex flex-col gap-2 flex-1 min-h-0">
|
2026-02-25 10:06:50 +01:00
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
accept={accept}
|
2026-03-01 08:22:17 +01:00
|
|
|
|
onChange={(e) => handleFiles(Array.from(e.target.files || []))}
|
2026-02-25 10:06:50 +01:00
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-03-01 08:22:17 +01:00
|
|
|
|
{selectedFiles.length === 0 ? (
|
|
|
|
|
|
/* ── Drop zone ─────────────────────────────────────── */
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={triggerInput}
|
|
|
|
|
|
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
|
|
|
|
|
|
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
|
|
|
|
|
onDragOver={(e) => e.preventDefault()}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex-1 flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer',
|
|
|
|
|
|
'text-center select-none',
|
|
|
|
|
|
isDragging
|
|
|
|
|
|
? 'border-primary bg-primary/10 scale-[0.99]'
|
|
|
|
|
|
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
|
|
|
|
|
|
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
|
|
|
|
|
|
isDragging ? 'bg-primary/25' : 'bg-primary/10'
|
|
|
|
|
|
)}>
|
|
|
|
|
|
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm font-medium text-foreground/70 mb-1">
|
|
|
|
|
|
{isDragging ? 'Drop to upload' : 'Drop files or click to browse'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/35 font-mono">
|
|
|
|
|
|
Video · Audio · Image · Max {maxSizeMB}MB
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* ── File list ─────────────────────────────────────── */
|
|
|
|
|
|
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-2 pr-0.5">
|
|
|
|
|
|
{selectedFiles.map((file, idx) => {
|
|
|
|
|
|
const meta = fileMetadata[idx];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`${file.name}-${idx}`}
|
|
|
|
|
|
className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
|
|
|
|
|
<CategoryIcon format={inputFormat} className="w-4 h-4" />
|
2026-02-26 17:52:41 +01:00
|
|
|
|
</div>
|
2026-02-25 19:59:22 +01:00
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
2026-03-01 08:22:17 +01:00
|
|
|
|
<p className="text-xs font-mono text-foreground/80 truncate" title={file.name}>
|
2026-02-25 19:59:22 +01:00
|
|
|
|
{file.name}
|
|
|
|
|
|
</p>
|
2026-03-01 08:22:17 +01:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); onFileRemove(idx); }}
|
2026-02-25 19:59:22 +01:00
|
|
|
|
disabled={disabled}
|
2026-03-01 08:22:17 +01:00
|
|
|
|
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
|
2026-02-25 19:59:22 +01:00
|
|
|
|
>
|
2026-03-01 08:22:17 +01:00
|
|
|
|
<X className="w-3 h-3" />
|
|
|
|
|
|
</button>
|
2026-02-25 19:59:22 +01:00
|
|
|
|
</div>
|
2026-03-01 08:22:17 +01:00
|
|
|
|
{meta && (
|
|
|
|
|
|
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
|
|
|
|
|
|
<span className="flex items-center gap-1"><HardDrive className="w-2.5 h-2.5" />{meta.size}</span>
|
|
|
|
|
|
{meta.duration && <span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5" />{meta.duration}</span>}
|
|
|
|
|
|
{meta.dimensions && <span className="flex items-center gap-1"><Film className="w-2.5 h-2.5" />{meta.dimensions}</span>}
|
2026-02-25 19:59:22 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-25 10:06:50 +01:00
|
|
|
|
</div>
|
2026-03-01 08:22:17 +01:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-02-25 10:06:50 +01:00
|
|
|
|
|
2026-03-01 08:22:17 +01:00
|
|
|
|
{/* Add more */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={triggerInput}
|
2026-02-25 10:06:50 +01:00
|
|
|
|
disabled={disabled}
|
2026-03-01 08:22:17 +01:00
|
|
|
|
className="shrink-0 w-full py-2 rounded-xl border border-dashed border-border/30 text-xs text-muted-foreground/40 hover:text-foreground hover:border-primary/30 transition-all flex items-center justify-center gap-1.5 font-mono"
|
2026-02-25 10:06:50 +01:00
|
|
|
|
>
|
2026-03-01 08:22:17 +01:00
|
|
|
|
<Upload className="w-3 h-3" />
|
|
|
|
|
|
Add more files
|
|
|
|
|
|
</button>
|
2026-02-25 10:06:50 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|