Files
kit-ui/components/media/FileUpload.tsx
Sebastian Krüger 2763b76abe refactor: refactor media tool to match calculate blueprint
Rewrites all three media components to use the glass panel design
language, fixed-height two-panel layout, and glass action buttons.

- FileConverter: lg:grid-cols-5 split — left 2/5 is the upload zone;
  right 3/5 has output format pill grid + codec/quality options +
  convert/reset buttons + scrollable results panel; mobile 'Upload |
  Convert' tab switcher auto-advances on file selection; removed all
  Card/Button/Label/Input shadcn imports; keeps Select+Slider for
  codec/quality controls
- FileUpload: large flex-1 drop zone fills the left panel; file list
  shows glass item cards with metadata chips; native buttons; removes
  shadcn Button dependency
- ConversionPreview: glass card replaces Card; native progress bar
  (div with bg-primary/65) replaces shadcn Progress; size reduction
  shown as emerald/muted badge; media previews in dark-bordered
  containers; all buttons are glass action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:22:17 +01:00

210 lines
9.0 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 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;
}
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} />;
}
}
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, Record<string, string>>>({});
const localRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localRef;
React.useEffect(() => {
const extract = async () => {
if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; }
const out: Record<number, Record<string, string>> = {};
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const base = {
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` : `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
type: inputFormat.name,
};
if (inputFormat.category === 'video' && file.type.startsWith('video/')) {
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); };
video.src = URL.createObjectURL(file);
});
} else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) {
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); };
audio.src = URL.createObjectURL(file);
});
} else if (inputFormat.category === 'image' && file.type.startsWith('image/')) {
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); };
img.src = URL.createObjectURL(file);
});
} else {
out[i] = base;
}
}
setFileMetadata(out);
};
extract();
}, [selectedFiles, inputFormat]);
const handleFiles = (files: File[]) => {
const maxBytes = maxSizeMB * 1024 * 1024;
const valid = files.filter((f) => {
if (f.size > maxBytes) { alert(`${f.name} exceeds ${maxSizeMB}MB limit.`); return false; }
return true;
});
if (valid.length > 0) onFileSelect(valid);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!disabled) handleFiles(Array.from(e.dataTransfer.files));
};
const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); };
return (
<div className="w-full flex flex-col gap-2 flex-1 min-h-0">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept={accept}
onChange={(e) => handleFiles(Array.from(e.target.files || []))}
disabled={disabled}
/>
{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" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-mono text-foreground/80 truncate" title={file.name}>
{file.name}
</p>
<button
onClick={(e) => { e.stopPropagation(); onFileRemove(idx); }}
disabled={disabled}
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
{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>}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Add more */}
<button
onClick={triggerInput}
disabled={disabled}
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"
>
<Upload className="w-3 h-3" />
Add more files
</button>
</div>
)}
</div>
);
}