Files
kit-ui/components/media/FileConverter.tsx
Sebastian Krüger 6ecdc33933 feat: add cardBtn style for card title row buttons
Smaller variant for buttons that sit next to section labels in card headers
(Preview, Color, Results rows). Applied to QRPreview, FontPreview,
ColorManipulation, and FileConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:36:19 +01:00

495 lines
23 KiB
TypeScript

'use client';
import * as React from 'react';
import { SliderRow } from '@/components/ui/slider-row';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import { FileUpload } from './FileUpload';
import { ConversionPreview } from './ConversionPreview';
import { toast } from 'sonner';
import {
getFormatByExtension,
getFormatByMimeType,
getCompatibleFormats,
} from '@/lib/media/utils/formatMappings';
import { convertWithFFmpeg } from '@/lib/media/converters/ffmpegService';
import { convertWithImageMagick } from '@/lib/media/converters/imagemagickService';
import { addToHistory } from '@/lib/media/storage/history';
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media';
import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
type MobileTab = 'upload' | 'convert';
const selectCls =
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer disabled:opacity-40';
export function FileConverter() {
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
const [mobileTab, setMobileTab] = React.useState<MobileTab>('upload');
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Detect format when files change
React.useEffect(() => {
if (selectedFiles.length === 0) {
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJobs([]);
return;
}
const first = selectedFiles[0];
const ext = first.name.split('.').pop()?.toLowerCase();
const fmt = (ext ? getFormatByExtension(ext) : undefined) ?? getFormatByMimeType(first.type);
if (fmt) {
setInputFormat(fmt);
const compat = getCompatibleFormats(fmt);
setCompatibleFormats(compat);
if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]);
toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`);
setMobileTab('convert');
} else {
toast.error('Could not detect file format');
setInputFormat(undefined);
setCompatibleFormats([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFiles]);
const runConversion = async (
jobIndex: number,
jobs: ConversionJob[],
outFmt: ConversionFormat
) => {
const job = jobs[jobIndex];
const updateJob = (patch: Partial<ConversionJob>) =>
setConversionJobs((prev) => prev.map((j, i) => (i === jobIndex ? { ...j, ...patch } : j)));
try {
updateJob({ status: 'loading' });
updateJob({ status: 'processing', progress: 10 });
const onProgress = (progress: number) => updateJob({ progress });
const result =
outFmt.converter === 'ffmpeg'
? await convertWithFFmpeg(job.inputFile, outFmt.extension, conversionOptions, onProgress)
: await convertWithImageMagick(job.inputFile, outFmt.extension, conversionOptions, onProgress);
if (result.success && result.blob) {
updateJob({ status: 'completed', progress: 100, result: result.blob, endTime: Date.now() });
addToHistory({
inputFileName: job.inputFile.name,
inputFormat: job.inputFormat.name,
outputFormat: outFmt.name,
outputFileName: generateOutputFilename(job.inputFile.name, outFmt.extension),
fileSize: result.blob.size,
result: result.blob,
});
return true;
} else {
updateJob({ status: 'error', error: result.error || 'Unknown error', endTime: Date.now() });
return false;
}
} catch (err) {
updateJob({ status: 'error', error: err instanceof Error ? err.message : 'Unknown error', endTime: Date.now() });
return false;
}
};
const handleConvert = async () => {
if (!selectedFiles.length || !inputFormat || !outputFormat) {
toast.error('Please select files and an output format');
return;
}
const jobs: ConversionJob[] = selectedFiles.map((file) => ({
id: Math.random().toString(36).slice(2, 9),
inputFile: file,
inputFormat: inputFormat!,
outputFormat,
options: conversionOptions,
status: 'pending',
progress: 0,
startTime: Date.now(),
}));
setConversionJobs(jobs);
let ok = 0;
for (let i = 0; i < jobs.length; i++) {
const success = await runConversion(i, jobs, outputFormat);
if (success) ok++;
}
if (ok === jobs.length) toast.success(`All ${jobs.length} file${jobs.length > 1 ? 's' : ''} converted!`);
else if (ok > 0) toast.info(`${ok}/${jobs.length} files converted`);
else toast.error('All conversions failed');
};
const handleRetry = async (jobId: string) => {
const idx = conversionJobs.findIndex((j) => j.id === jobId);
if (idx === -1 || !outputFormat) return;
setConversionJobs((prev) =>
prev.map((j, i) =>
i === idx ? { ...j, status: 'loading', progress: 0, error: undefined, startTime: Date.now() } : j
)
);
const success = await runConversion(idx, conversionJobs, outputFormat);
if (success) toast.success('Conversion completed!');
else toast.error('Retry failed');
};
const handleReset = () => {
setSelectedFiles([]);
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJobs([]);
setConversionOptions({});
setMobileTab('upload');
};
const handleDownloadAll = async () => {
if (!outputFormat) return;
const done = conversionJobs.filter((j) => j.status === 'completed' && j.result);
if (!done.length) { toast.error('No completed files'); return; }
if (done.length === 1) {
const url = URL.createObjectURL(done[0].result!);
const a = document.createElement('a');
a.href = url;
a.download = generateOutputFilename(done[0].inputFile.name, outputFormat.extension);
a.click();
URL.revokeObjectURL(url);
return;
}
await downloadBlobsAsZip(
done.map((j) => ({ blob: j.result!, filename: generateOutputFilename(j.inputFile.name, outputFormat.extension) })),
'converted-files.zip'
);
toast.success(`Downloaded ${done.length} files as ZIP`);
};
const isConverting = conversionJobs.some((j) => j.status === 'loading' || j.status === 'processing');
const completedCount = conversionJobs.filter((j) => j.status === 'completed').length;
const setOpt = (patch: Partial<ConversionOptions>) =>
setConversionOptions((prev) => ({ ...prev, ...patch }));
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'upload', label: 'Upload' }, { value: 'convert', label: 'Convert' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: upload zone */}
<div
className={cn(
'lg:col-span-2 flex flex-col overflow-hidden',
mobileTab !== 'upload' && 'hidden lg:flex'
)}
>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Upload
</span>
<span className="flex items-center gap-1 text-[10px] text-emerald-400/60 font-mono">
<ShieldCheck className="w-3 h-3" />
Zero uploads
</span>
</div>
<FileUpload
onFileSelect={(files) => setSelectedFiles((prev) => [...prev, ...files])}
onFileRemove={(i) => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))}
selectedFiles={selectedFiles}
disabled={isConverting}
inputRef={fileInputRef}
inputFormat={inputFormat}
/>
</div>
</div>
{/* Right: options + results */}
<div
className={cn(
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'convert' && 'hidden lg:flex'
)}
>
{inputFormat && compatibleFormats.length > 0 ? (
<div className="glass rounded-xl p-4 shrink-0">
{/* Detected format */}
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Output Format
</span>
{inputFormat && (
<span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{inputFormat.name}
</span>
)}
</div>
{/* Format pill grid */}
<div className="flex flex-wrap gap-1.5 mb-4">
{compatibleFormats.map((fmt) => (
<button
key={fmt.id}
onClick={() => setOutputFormat(fmt)}
disabled={isConverting}
className={cn(
'px-2.5 py-1 rounded-lg border text-xs font-mono transition-all',
outputFormat?.id === fmt.id
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground',
isConverting && 'opacity-40 cursor-not-allowed'
)}
>
.{fmt.extension}
</button>
))}
</div>
{outputFormat && (
<>
<div className="border-t border-border/25 pt-3 space-y-3">
{/* Video options */}
{outputFormat.category === 'video' && (
<>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Codec</span>
<select
value={conversionOptions.videoCodec || 'default'}
onChange={(e) => setOpt({ videoCodec: e.target.value === 'default' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="default">Auto (Recommended)</option>
<option value="libx264">H.264</option>
<option value="libx265">H.265</option>
<option value="libvpx">VP8 (WebM)</option>
<option value="libvpx-vp9">VP9 (WebM)</option>
</select>
</div>
<SliderRow
label="Video Bitrate"
display={conversionOptions.videoBitrate || '2M'}
value={parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')}
min={0.5} max={10} step={0.5}
onChange={(v) => setOpt({ videoBitrate: `${v}M` })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Resolution</span>
<select
value={conversionOptions.videoResolution || 'original'}
onChange={(e) => setOpt({ videoResolution: e.target.value === 'original' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="1920x-1">1080p</option>
<option value="1280x-1">720p</option>
<option value="854x-1">480p</option>
<option value="640x-1">360p</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">FPS</span>
<select
value={conversionOptions.videoFps?.toString() || 'original'}
onChange={(e) => setOpt({ videoFps: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="60">60 fps</option>
<option value="30">30 fps</option>
<option value="24">24 fps</option>
<option value="15">15 fps</option>
</select>
</div>
</div>
<SliderRow
label="Audio Bitrate"
display={conversionOptions.audioBitrate || '128k'}
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')}
min={64} max={320} step={32}
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
disabled={isConverting}
/>
</>
)}
{/* Audio options */}
{outputFormat.category === 'audio' && (
<>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Codec</span>
<select
value={conversionOptions.audioCodec || 'default'}
onChange={(e) => setOpt({ audioCodec: e.target.value === 'default' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="default">Auto</option>
<option value="libmp3lame">MP3 (LAME)</option>
<option value="aac">AAC</option>
<option value="libvorbis">Vorbis</option>
<option value="libopus">Opus</option>
<option value="flac">FLAC</option>
</select>
</div>
<SliderRow
label="Bitrate"
display={conversionOptions.audioBitrate || '192k'}
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')}
min={64} max={320} step={32}
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Sample Rate</span>
<select
value={conversionOptions.audioSampleRate?.toString() || 'original'}
onChange={(e) => setOpt({ audioSampleRate: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="48000">48 kHz</option>
<option value="44100">44.1 kHz</option>
<option value="22050">22 kHz</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Channels</span>
<select
value={conversionOptions.audioChannels?.toString() || 'original'}
onChange={(e) => setOpt({ audioChannels: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="2">Stereo</option>
<option value="1">Mono</option>
</select>
</div>
</div>
</>
)}
{/* Image options */}
{outputFormat.category === 'image' && (
<>
<SliderRow
label="Quality"
display={`${conversionOptions.imageQuality ?? 85}%`}
value={conversionOptions.imageQuality ?? 85}
min={1} max={100} step={1}
onChange={(v) => setOpt({ imageQuality: v })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
{(['imageWidth', 'imageHeight'] as const).map((key) => (
<div key={key} className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{key === 'imageWidth' ? 'Width (px)' : 'Height (px)'}
</span>
<input
type="number"
value={conversionOptions[key] ?? ''}
onChange={(e) => setOpt({ [key]: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Original"
disabled={isConverting}
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 disabled:opacity-40"
/>
</div>
))}
</div>
</>
)}
</div>
{/* Action buttons */}
<div className="flex gap-2 mt-4 pt-3 border-t border-border/25">
<button
onClick={handleConvert}
disabled={!selectedFiles.length || !outputFormat || isConverting}
className={cn(actionBtn, 'flex-1 justify-center py-2')}
>
{isConverting
? <><Loader2 className="w-3 h-3 animate-spin" />Converting</>
: `Convert ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}`
}
</button>
<button onClick={handleReset} className={actionBtn} title="Reset">
<RotateCcw className="w-3 h-3" />
</button>
</div>
</>
)}
</div>
) : (
/* No files yet — right panel placeholder */
<div className="glass rounded-xl p-4 flex flex-col items-center justify-center flex-1 min-h-0 text-center">
<div className="w-12 h-12 rounded-full bg-primary/8 flex items-center justify-center mb-3">
<ShieldCheck className="w-5 h-5 text-primary/30" />
</div>
<p className="text-xs text-muted-foreground/30 font-mono">
Upload files to see conversion options
</p>
</div>
)}
{/* Results panel */}
{conversionJobs.length > 0 && (
<div className="glass rounded-xl p-3 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Results
</span>
{completedCount > 0 && (
<button onClick={handleDownloadAll} className={cardBtn}>
<Download className="w-3 h-3" />
{completedCount > 1 ? `Download all (${completedCount}) as ZIP` : 'Download'}
</button>
)}
</div>
<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">
{conversionJobs.map((job) => (
<ConversionPreview
key={job.id}
job={job}
onRetry={() => handleRetry(job.id)}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}