// src/renderer/components/FileDropZone.tsx // Phase 12.1: 파일 전사 드래그앤드롭 UI import { useState, useEffect, useCallback, useRef } from 'react' import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material' import { Copy, X, FileUp } from 'lucide-react' import { MetalCard, PhosphorText, Led } from '@d3ro/ui/components/ds' import { d3roPalette, d3roTypo, d3roRadius } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { FileTranscriptionProgress, FileTranscriptionResult, FileTranscriptionState, } from '@d3ro/core/types' const SUPPORTED_EXTENSIONS = [ '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac', '.mp4', '.mkv', '.webm', '.avi', '.mov', ] export function FileDropZone(): React.ReactElement { const { t } = useI18n() const [dragging, setDragging] = useState(false) const [state, setState] = useState('idle') const [progress, setProgress] = useState(null) const [result, setResult] = useState(null) const [error, setError] = useState(null) const [copied, setCopied] = useState(false) const dropRef = useRef(null) useEffect(() => { const unsubProgress = window.electronAPI.fileTranscription.onProgress((data) => { setProgress(data) setState('transcribing') }) const unsubComplete = window.electronAPI.fileTranscription.onComplete((data) => { setResult(data) setState('completed') setProgress(null) }) const unsubError = window.electronAPI.fileTranscription.onError((data) => { setError(data.message) setState('error') setProgress(null) }) return () => { unsubProgress() unsubComplete() unsubError() } }, []) const isBusyRef = useRef(false) const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault() setDragging(false) if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return const file = e.dataTransfer.files[0] if (!file) return const ext = '.' + file.name.split('.').pop()?.toLowerCase() if (!SUPPORTED_EXTENSIONS.includes(ext)) { setError(t('fileTranscription.error.invalidFormat')) setState('error') return } isBusyRef.current = true setState('converting') setError(null) setResult(null) try { const filePath = (file as unknown as { path: string }).path const resp = await window.electronAPI.fileTranscription.start({ filePath }) if (!resp.success) { setError(resp.error.message) setState('error') } } finally { isBusyRef.current = false } }, [t, state]) const handleBrowse = useCallback(async () => { if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return isBusyRef.current = true setState('converting') setError(null) setResult(null) try { const resp = await window.electronAPI.fileTranscription.start({ filePath: '' }) if (!resp.success) { if (resp.error.message.includes('cancelled') || resp.error.message.includes('already active')) { setState('idle') } else { setError(resp.error.message) setState('error') } } } finally { isBusyRef.current = false } }, [state]) const handleCancel = useCallback(async () => { await window.electronAPI.fileTranscription.cancel() setState('idle') setProgress(null) isBusyRef.current = false }, []) const handleCopy = useCallback(() => { if (result?.fullText) { navigator.clipboard.writeText(result.fullText) setCopied(true) setTimeout(() => setCopied(false), 2000) } }, [result]) const handleReset = useCallback(() => { setState('idle') setResult(null) setError(null) setProgress(null) }, []) // ── idle: 드래그 존 ── if (state === 'idle') { return ( { e.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={handleDrop} onClick={handleBrowse} sx={{ p: 4, textAlign: 'center', border: `2px dashed ${dragging ? d3roPalette.accent.main : d3roPalette.border.subtle}`, borderRadius: d3roRadius.small, cursor: 'pointer', transition: 'border-color 0.2s', '&:hover': { borderColor: d3roPalette.accent.main }, }} > {t('fileTranscription.dropZone')} {t('fileTranscription.dropZoneHint')} ) } // ── converting / transcribing: 진행률 ── if (state === 'converting' || state === 'transcribing') { return ( {state === 'converting' ? t('fileTranscription.converting') : t('fileTranscription.processing')} {t('fileTranscription.cancel')} {progress && ( <> {t('fileTranscription.progress', { current: String(progress.currentChunk), total: String(progress.totalChunks), })} {progress.currentText && ( {progress.currentText.slice(0, 100)}... )} )} ) } // ── completed: 결과 ── if (state === 'completed' && result) { return ( {t('fileTranscription.complete')} ({result.fileName}) {result.fullText} {Math.round(result.totalDurationSec)}s audio / {Math.round(result.processingTimeMs / 1000)}s processing ) } // ── error ── if (state === 'error') { return ( {error ?? t('fileTranscription.error.unknown')} {t('fileTranscription.retry')} ) } return <> }