import { useCallback, useMemo, useRef, useState } from 'react'; import { Upload, Trash2 } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; import type { ToolbarAction } from '@/components/TextInputArea'; import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; import { fileToBase64, isFileSizeValid, formatFileSize, base64ToBlob, downloadBlob, MAX_FILE_SIZE, } from '@/utils/base64Converter'; import type { Base64ToBlobResult, FileToBase64Result } from '@/utils/base64Converter'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConvertDirection } from '@/types/storage'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; interface FileInfo { name: string; size: number; type: string; } const ERROR_MESSAGE_TO_I18N: Record = { 'Invalid Base64 string': 'invalidBase64', }; const isValidDirection = (val: unknown): val is Base64ConvertDirection => val === 'encode' || val === 'decode'; export default function FileMode() { const { t } = useTranslation('base64Converter'); const [direction, setDirection] = useStorageState( 'base64Converter/fileMode/direction', 'encode', isValidDirection, ); // encode state const [result, setResult] = useState(null); const [info, setInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const cancelRef = useRef(false); // decode state const [decodeInput, setDecodeInput] = useState(''); const [decoded, setDecoded] = useState(null); const [decodedFileName, setDecodedFileName] = useState(''); // shared const [error, setError] = useState(null); const resetAll = useCallback(() => { cancelRef.current = true; setResult(null); setInfo(null); setIsLoading(false); setDecodeInput(''); setDecoded(null); setDecodedFileName(''); setError(null); if (fileInputRef.current) fileInputRef.current.value = ''; }, []); const handleClear = () => { resetAll(); }; const handleDirectionChange = (next: Base64ConvertDirection) => { if (next === direction) return; resetAll(); setDirection(next); }; const handleFileSelect = async (file: File) => { cancelRef.current = false; setError(null); setResult(null); setInfo(null); if (!isFileSizeValid(file.size)) { setError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); return; } setInfo({ name: file.name, size: file.size, type: file.type || 'application/octet-stream', }); setIsLoading(true); try { const res = await fileToBase64(file); if (!cancelRef.current) setResult(res); } catch (e) { if (!cancelRef.current) { setError(e instanceof Error ? e.message : t('conversionFailed')); } } finally { if (!cancelRef.current) setIsLoading(false); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) handleFileSelect(file); }; const handleDownload = () => { if (!decoded) return; downloadBlob(decoded.blob, decodedFileName || `decoded${decoded.suggestedExtension}`); }; const actions: ToolbarAction[] = useMemo( () => [ { key: 'decode', label: t('decode'), type: 'primary', position: 'bottom', disabled: (value: string) => !value.trim(), onClick: (value: string, helpers) => { helpers.setError(''); setDecoded(null); try { const res = base64ToBlob(value); setDecoded(res); setDecodedFileName(`decoded${res.suggestedExtension}`); } catch (e) { const message = e instanceof Error ? e.message : ''; const i18nKey = ERROR_MESSAGE_TO_I18N[message]; helpers.setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); } }, }, ], [t], ); return ( <> {direction === 'encode' && ( <>
fileInputRef.current?.click()} className={`flex flex-col items-center justify-center min-h-[180px] border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all duration-200 ${ isDragging ? 'border-primary bg-primary/10' : info ? 'border-primary bg-primary/5' : 'border-input bg-muted hover:border-primary hover:bg-primary/5' }`} > { const file = e.target.files?.[0]; if (file) handleFileSelect(file); }} /> {isLoading ? (
) : info ? (
{info.name} {formatFileSize(info.size)} ยท {info.type} {t('clickOrDropToReplace')}
) : (
{t('clickOrDropToFile')} {t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
)}
{error && (
{error}
)} {result && (
{t('base64Output')}
2000 ? `${result.output.substring(0, 2000)}...` : result.output } showClear={false} showCount />
{t('originalSize')}: {formatFileSize(result.originalBytes)} {t('encodedSize')}: {formatFileSize(result.outputBytes)}
)} {info && !result && ( )} )} {direction === 'decode' && ( <> { setDecodeInput(v); setError(null); }} actions={actions} externalError={error || undefined} onClear={() => { setDecoded(null); setDecodedFileName(''); }} /> {decoded && ( )} )} ); }