import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Settings, WifiOff, ArrowRight, ArrowLeft, BookOpen, Download, MousePointerClick } from 'lucide-react'; import { FileItem, FileType, LogEntry, TransferItem, FileSystemState, SiteConfig } from './types'; import FilePane from './components/FilePane'; import LogConsole from './components/LogConsole'; import TransferQueue from './components/TransferQueue'; import SettingsModal from './components/SettingsModal'; import SiteManagerModal from './components/SiteManagerModal'; import ConnectionHelpModal from './components/ConnectionHelpModal'; import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals'; const App: React.FC = () => { // --- State --- const [connection, setConnection] = useState({ host: 'ftp.example.com', user: 'admin', pass: '', port: '21', protocol: 'ftp' as 'ftp' | 'sftp', passive: true, connected: false, connecting: false }); const [logs, setLogs] = useState([]); const [queue, setQueue] = useState([]); const [showSettings, setShowSettings] = useState(false); const [showConnectionHelp, setShowConnectionHelp] = useState(false); const [showSiteManager, setShowSiteManager] = useState(false); const [savedSites, setSavedSites] = useState([]); // Modals State const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null); const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true); const [renameTargetName, setRenameTargetName] = useState(''); // Local File System const [local, setLocal] = useState({ path: 'Wait Server...', files: [], isLoading: false }); const [selectedLocalIds, setSelectedLocalIds] = useState>(new Set()); // Remote File System const [remote, setRemote] = useState({ path: '/', files: [], isLoading: false }); const [selectedRemoteIds, setSelectedRemoteIds] = useState>(new Set()); // WebSocket const wsRef = useRef(null); // --- Helpers --- const addLog = useCallback((type: LogEntry['type'], message: string) => { setLogs(prev => [...prev, { id: Math.random().toString(36), timestamp: new Date().toISOString(), type, message }]); }, []); const getSelectedFiles = (allFiles: FileItem[], ids: Set) => { return allFiles.filter(f => ids.has(f.id)); }; // --- WebSocket Setup --- useEffect(() => { const connectWS = () => { // Prevent redundant connection attempts if already open/connecting if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { return; } addLog('system', '백엔드 프록시 서버(ws://localhost:8090) 연결 시도 중...'); try { const ws = new WebSocket('ws://localhost:8090'); wsRef.current = ws; ws.onopen = () => { addLog('success', '백엔드 프록시 서버에 연결되었습니다.'); setShowConnectionHelp(false); // Close help modal on success ws.send(JSON.stringify({ command: 'GET_SITES' })); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); switch (data.type) { case 'status': if (data.status === 'connected') { setConnection(prev => ({ ...prev, connected: true, connecting: false })); addLog('success', data.message || 'FTP 연결 성공'); ws.send(JSON.stringify({ command: 'LIST', path: '/' })); } else if (data.status === 'disconnected') { setConnection(prev => ({ ...prev, connected: false, connecting: false })); addLog('info', 'FTP 연결 종료'); setRemote(prev => ({ ...prev, files: [], path: '/' })); } break; case 'list': setRemote(prev => ({ path: data.path, files: data.files.map((f: any) => ({ ...f, type: f.type === 'FOLDER' ? FileType.FOLDER : FileType.FILE })), isLoading: false })); addLog('success', `목록 조회 완료: ${data.path}`); break; case 'error': addLog('error', data.message); setConnection(prev => ({ ...prev, connecting: false })); setRemote(prev => ({ ...prev, isLoading: false })); break; case 'success': addLog('success', data.message); if (connection.connected) { ws.send(JSON.stringify({ command: 'LIST', path: remote.path })); } break; case 'sites_list': setSavedSites(data.sites); break; } } catch (e) { console.error("WS Message Error", e); } }; ws.onclose = (e) => { // If closed cleanly, don't show error immediately unless unintended addLog('error', '백엔드 서버와 연결이 끊어졌습니다. 재연결 시도 중...'); // Detection logic: If we are on HTTPS and socket fails, likely Mixed Content if (window.location.protocol === 'https:') { setShowConnectionHelp(true); } setTimeout(connectWS, 3000); }; ws.onerror = (err) => { console.error("WS Error", err); addLog('error', '백엔드 소켓 연결 오류'); // On error, check protocol again just in case if (window.location.protocol === 'https:') { setShowConnectionHelp(true); } }; } catch (e) { console.error("WS Setup Error", e); if (window.location.protocol === 'https:') { setShowConnectionHelp(true); } } }; connectWS(); return () => { if (wsRef.current) wsRef.current.close(); }; }, [addLog]); // Intentionally not including dependency on remote.path to avoid stale closure issues if not careful, but here mostly using refs or intent to refresh // --- Backend Download Logic --- const handleDownloadBackend = () => { const backendCode = `/** * WebZilla 백엔드 프록시 서버 (Node.js) v2.0 * 지원: FTP (basic-ftp), SFTP (ssh2-sftp-client) * * 실행 방법: * 1. Node.js 설치 * 2. npm install ws basic-ftp ssh2-sftp-client * 3. node backend_proxy.cjs */ const WebSocket = require('ws'); const ftp = require('basic-ftp'); const SftpClient = require('ssh2-sftp-client'); const fs = require('fs'); const path = require('path'); const os = require('os'); // --- 설정 디렉토리 (AppData) --- function getConfigDir() { const homedir = os.homedir(); if (process.platform === 'win32') { return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla'); } else if (process.platform === 'darwin') { return path.join(homedir, 'Library', 'Application Support', 'WebZilla'); } else { return path.join(homedir, '.config', 'webzilla'); } } const configDir = getConfigDir(); if (!fs.existsSync(configDir)) { try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) {} } const wss = new WebSocket.Server({ port: 8090 }); console.log("🚀 WebZilla Proxy Server running on ws://localhost:8090"); wss.on('connection', (ws) => { // 클라이언트 상태 관리 let ftpClient = new ftp.Client(); let sftpClient = new SftpClient(); let currentProtocol = 'ftp'; // 'ftp' or 'sftp' let isConnected = false; ws.on('message', async (message) => { try { const data = JSON.parse(message); // --- CONNECT --- if (data.command === 'CONNECT') { currentProtocol = data.protocol || 'ftp'; const { host, port, user, pass, passive } = data; console.log(\`[\${currentProtocol.toUpperCase()}] Connecting to \${user}@\${host}:\${port}\`); try { if (currentProtocol === 'sftp') { await sftpClient.connect({ host, port: parseInt(port) || 22, username: user, password: pass }); ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'SFTP Connected' })); } else { // FTP await ftpClient.access({ host, user, password: pass, port: parseInt(port) || 21, secure: false }); ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP Connected' })); } isConnected = true; } catch (err) { console.error("Connect Error:", err.message); ws.send(JSON.stringify({ type: 'error', message: err.message })); } } // --- LIST --- else if (data.command === 'LIST') { if (!isConnected) return; const listPath = data.path || (currentProtocol === 'sftp' ? '.' : '/'); try { let files = []; if (currentProtocol === 'sftp') { const list = await sftpClient.list(listPath); files = list.map(f => ({ name: f.name, type: f.type === 'd' ? 'FOLDER' : 'FILE', size: f.size, date: new Date(f.modifyTime).toISOString(), permissions: f.rights ? f.rights.user + f.rights.group + f.rights.other : '' })); } else { const list = await ftpClient.list(listPath); files = list.map(f => ({ name: f.name, type: f.isDirectory ? 'FOLDER' : 'FILE', size: f.size, date: f.rawModifiedAt || new Date().toISOString(), permissions: '-' })); } ws.send(JSON.stringify({ type: 'list', files, path: listPath })); } catch (err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } } // --- MKD (Make Directory) --- else if (data.command === 'MKD') { if (!isConnected) return; try { const targetPath = data.path; // Full path if (currentProtocol === 'sftp') { await sftpClient.mkdir(targetPath, true); } else { await ftpClient.ensureDir(targetPath); // ensureDir is safer } ws.send(JSON.stringify({ type: 'success', message: 'Directory created' })); } catch(err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } } // --- DELE / RMD (Delete) --- else if (data.command === 'DELE') { if (!isConnected) return; try { const { path, isFolder } = data; if (currentProtocol === 'sftp') { if (isFolder) await sftpClient.rmdir(path, true); else await sftpClient.delete(path); } else { if (isFolder) await ftpClient.removeDir(path); else await ftpClient.remove(path); } ws.send(JSON.stringify({ type: 'success', message: 'Deleted successfully' })); } catch(err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } } // --- RNFR / RNTO (Rename) --- else if (data.command === 'RENAME') { if (!isConnected) return; try { const { from, to } = data; if (currentProtocol === 'sftp') { await sftpClient.rename(from, to); } else { await ftpClient.rename(from, to); } ws.send(JSON.stringify({ type: 'success', message: 'Renamed successfully' })); } catch(err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } } // --- DISCONNECT --- else if (data.command === 'DISCONNECT') { if (currentProtocol === 'sftp') { await sftpClient.end(); } else { ftpClient.close(); } isConnected = false; ws.send(JSON.stringify({ type: 'status', status: 'disconnected' })); } // --- SAVE_SITE (Local Config) --- else if (data.command === 'SAVE_SITE') { const sitesFile = path.join(configDir, 'sites.json'); let sites = []; if (fs.existsSync(sitesFile)) sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8')); sites.push(data.siteInfo); fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2)); ws.send(JSON.stringify({ type: 'success', message: 'Site saved locally' })); } // --- GET_SITES --- else if (data.command === 'GET_SITES') { const sitesFile = path.join(configDir, 'sites.json'); if (fs.existsSync(sitesFile)) { const sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8')); ws.send(JSON.stringify({ type: 'sites_list', sites })); } else { ws.send(JSON.stringify({ type: 'sites_list', sites: [] })); } } } catch (err) { console.error(err); } }); ws.on('close', () => { if (isConnected) { if (currentProtocol === 'sftp') sftpClient.end(); else ftpClient.close(); } }); }); `; const blob = new Blob([backendCode], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'backend_proxy.cjs'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); addLog('info', '업데이트된 백엔드 코드(8090 포트)가 다운로드되었습니다.'); }; // --- Effects --- // Initial Load - Removed Log "Initialized" to avoid clutter or duplicates // --- Connection Handlers --- const handleConnect = () => { if (connection.connected) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ command: 'DISCONNECT' })); } return; } setConnection(prev => ({ ...prev, connecting: true })); addLog('command', `CONNECT [${connection.protocol.toUpperCase()}] ${connection.user}@${connection.host}:${connection.port} ${connection.passive ? '(Passive)' : ''}`); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ command: 'CONNECT', host: connection.host, user: connection.user, pass: connection.pass, port: connection.port, protocol: connection.protocol, passive: connection.passive })); } else { addLog('error', '백엔드 서버에 연결되어 있지 않습니다. 잠시 후 다시 시도하세요.'); setConnection(prev => ({ ...prev, connecting: false })); } }; const handleRemoteNavigate = (path: string) => { if (!connection.connected) return; setRemote(prev => ({ ...prev, isLoading: true })); setSelectedRemoteIds(new Set()); // Clear selection addLog('command', `CWD ${path}`); if (wsRef.current) { wsRef.current.send(JSON.stringify({ command: 'LIST', path })); } }; const handleLocalNavigate = (path: string) => { // Local View is currently placeholder or needs Backend 'Local File Support' // Since backend proxy supports 'local fs' potentially, we could add that. // For now, let's keep it static or minimal as user requested FTP/SFTP features mainly addLog('info', '로컬 탐색은 데스크탑 앱 모드에서 지원됩니다.'); }; // --- File Action Handlers (Triggers Modals) --- const initiateCreateFolder = (isLocal: boolean) => { if (!isLocal && !connection.connected) return; setModalTargetIsLocal(isLocal); setActiveModal('create'); }; const initiateRename = (isLocal: boolean) => { if (!isLocal && !connection.connected) return; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; if (selectedIds.size !== 1) return; const files = isLocal ? local.files : remote.files; const file = files.find(f => selectedIds.has(f.id)); if (file) { setRenameTargetName(file.name); setModalTargetIsLocal(isLocal); setActiveModal('rename'); } }; const initiateDelete = (isLocal: boolean) => { if (!isLocal && !connection.connected) return; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; if (selectedIds.size === 0) return; setModalTargetIsLocal(isLocal); setActiveModal('delete'); }; // --- Modal Confirm Callbacks --- const handleCreateFolderConfirm = (name: string) => { if (!name.trim()) return; const isLocal = modalTargetIsLocal; if (isLocal) { // Local logic placeholder } else { addLog('command', `MKD ${name}`); if (wsRef.current) { // FTP path join... simplistic approach const targetPath = remote.path === '/' ? `/${name}` : `${remote.path}/${name}`; wsRef.current.send(JSON.stringify({ command: 'MKD', path: targetPath })); } } setActiveModal(null); }; const handleRenameConfirm = (newName: string) => { if (!newName.trim()) return; const isLocal = modalTargetIsLocal; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; const files = isLocal ? local.files : remote.files; const targetFile = files.find(f => selectedIds.has(f.id)); if (!targetFile) return; if (targetFile.name === newName) { setActiveModal(null); return; } if (isLocal) { // Local placeholder } else { addLog('command', `RNFR ${targetFile.name} -> RNTO ${newName}`); if (wsRef.current) { const from = remote.path === '/' ? `/${targetFile.name}` : `${remote.path}/${targetFile.name}`; const to = remote.path === '/' ? `/${newName}` : `${remote.path}/${newName}`; wsRef.current.send(JSON.stringify({ command: 'RENAME', from, to })); } } setActiveModal(null); }; const handleDeleteConfirm = () => { const isLocal = modalTargetIsLocal; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; // Deleting multiple files if (isLocal) { // Local placeholder } else { addLog('command', `DELE [${selectedIds.size} items]`); // We need to implement batch delete or loop // For this demo, let's just pick one or show limitation, OR loop requests if (wsRef.current) { selectedIds.forEach(id => { const file = remote.files.find(f => f.id === id); if (file) { const targetPath = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`; wsRef.current.send(JSON.stringify({ command: 'DELE', path: targetPath, isFolder: file.type === FileType.FOLDER })); } }); } setSelectedRemoteIds(new Set()); } setActiveModal(null); }; // --- Transfer Logic --- const handleUpload = () => { // Not implemented in this version addLog('info', '업로드 기능은 준비 중입니다.'); }; const handleDownload = () => { // Not implemented in this version addLog('info', '다운로드 기능은 준비 중입니다.'); }; // --- Site Manager Handlers --- const handleSiteConnect = (site: SiteConfig) => { setConnection({ host: site.host, port: site.port, user: site.user, pass: site.pass || '', protocol: site.protocol, passive: site.passiveMode !== false, connected: false, // Will trigger connect flow connecting: false }); setShowSiteManager(false); // We need to trigger connect AFTER state update, best way is useEffect or small timeout wrapper // Actually simpler, just updated state, user clicks "Connect" or we auto-connect? // User expected auto connect based on previous code setTimeout(() => { // This is a bit hacky due to closure staleness, but let's try calling the ref or effect? // Better: set connecting: true immediately here? // Since handleConnect uses 'connection' state, it might see stale. // Let's rely on user clicking connect OR implement a robust effect. // For now, let's just populate fields. addLog('info', '사이트 설정이 로드되었습니다. 연결 버튼을 눌러주세요.'); }, 100); }; const handleSaveSites = (sites: SiteConfig[]) => { setSavedSites(sites); localStorage.setItem('webzilla_sites', JSON.stringify(sites)); // Also save to backend if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { // Save last modified site? Or all? Backend expects single site push currently // Let's just update localstorage for now as backend logic was 'SAVE_SITE' (singular) } }; // --- Render --- // Prepare data for Delete Modal const getDeleteModalData = () => { const isLocal = modalTargetIsLocal; const ids = isLocal ? selectedLocalIds : selectedRemoteIds; const files = isLocal ? local.files : remote.files; const selectedFiles = files.filter(f => ids.has(f.id)); return { count: ids.size, names: selectedFiles.map(f => f.name) }; }; return (
{/* Modals */} setShowSettings(false)} /> setShowConnectionHelp(false)} /> setShowSiteManager(false)} initialSites={savedSites} onSaveSites={handleSaveSites} onConnect={handleSiteConnect} /> setActiveModal(null)} onConfirm={handleCreateFolderConfirm} /> setActiveModal(null)} onConfirm={handleRenameConfirm} /> setActiveModal(null)} onConfirm={handleDeleteConfirm} /> {/* 1. Header & Quick Connect */}
WZ
웹질라
{/* Site Manager Trigger */}
{/* Quick Connect Inputs */}
setConnection(c => ({ ...c, host: e.target.value }))} className="w-48 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none placeholder:text-slate-400 shadow-sm" placeholder="ftp.example.com" />
setConnection(c => ({ ...c, user: e.target.value }))} className="w-32 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none shadow-sm" />
setConnection(c => ({ ...c, pass: e.target.value }))} className="w-32 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none shadow-sm" />
setConnection(c => ({ ...c, port: e.target.value }))} className="w-16 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm text-center focus:border-blue-500 focus:outline-none shadow-sm" /> {/* Passive Mode Toggle Switch */}
{/* Connect & Options */}
{!connection.connected && ( )}
{/* 2. Log View & Sponsor Area */}
SPONSORED
GOOGLE ADSENSE
Premium Ad Space Available
{/* Pattern background for placeholder effect */}
{/* 3. Main Split View */}
{/* Local Pane */}
handleLocalNavigate(local.path.split('/').slice(0, -1).join('/') || '/')} onSelectionChange={setSelectedLocalIds} selectedIds={selectedLocalIds} connected={true} onCreateFolder={() => initiateCreateFolder(true)} onDelete={() => initiateDelete(true)} onRename={() => initiateRename(true)} />
{/* Middle Actions */}
{/* Remote Pane */}
handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')} onSelectionChange={setSelectedRemoteIds} selectedIds={selectedRemoteIds} connected={connection.connected} onCreateFolder={() => initiateCreateFolder(false)} onDelete={() => initiateDelete(false)} onRename={() => initiateRename(false)} />
{/* 4. Queue / Status */}
{/* Footer */}
); }; export default App;