868 lines
35 KiB
TypeScript
868 lines
35 KiB
TypeScript
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<LogEntry[]>([]);
|
|
const [queue, setQueue] = useState<TransferItem[]>([]);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
|
|
const [showSiteManager, setShowSiteManager] = useState(false);
|
|
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
|
|
|
|
// 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<FileSystemState>({
|
|
path: 'Wait Server...',
|
|
files: [],
|
|
isLoading: false
|
|
});
|
|
const [selectedLocalIds, setSelectedLocalIds] = useState<Set<string>>(new Set());
|
|
|
|
// Remote File System
|
|
const [remote, setRemote] = useState<FileSystemState>({
|
|
path: '/',
|
|
files: [],
|
|
isLoading: false
|
|
});
|
|
const [selectedRemoteIds, setSelectedRemoteIds] = useState<Set<string>>(new Set());
|
|
|
|
// WebSocket
|
|
const wsRef = useRef<WebSocket | null>(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<string>) => {
|
|
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 (
|
|
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans">
|
|
{/* Modals */}
|
|
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
|
<ConnectionHelpModal isOpen={showConnectionHelp} onClose={() => setShowConnectionHelp(false)} />
|
|
<SiteManagerModal
|
|
isOpen={showSiteManager}
|
|
onClose={() => setShowSiteManager(false)}
|
|
initialSites={savedSites}
|
|
onSaveSites={handleSaveSites}
|
|
onConnect={handleSiteConnect}
|
|
/>
|
|
|
|
<CreateFolderModal
|
|
isOpen={activeModal === 'create'}
|
|
onClose={() => setActiveModal(null)}
|
|
onConfirm={handleCreateFolderConfirm}
|
|
/>
|
|
<RenameModal
|
|
isOpen={activeModal === 'rename'}
|
|
currentName={renameTargetName}
|
|
onClose={() => setActiveModal(null)}
|
|
onConfirm={handleRenameConfirm}
|
|
/>
|
|
<DeleteModal
|
|
isOpen={activeModal === 'delete'}
|
|
fileCount={getDeleteModalData().count}
|
|
fileNames={getDeleteModalData().names}
|
|
onClose={() => setActiveModal(null)}
|
|
onConfirm={handleDeleteConfirm}
|
|
/>
|
|
|
|
{/* 1. Header & Quick Connect */}
|
|
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
|
<div className="flex flex-col xl:flex-row gap-4 items-center">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center font-bold text-white shadow-lg shadow-blue-500/20">WZ</div>
|
|
<span className="font-bold text-lg tracking-tight hidden sm:inline text-slate-700">웹질라</span>
|
|
</div>
|
|
|
|
<div className="flex-1 w-full flex flex-wrap items-center gap-2">
|
|
|
|
{/* Site Manager Trigger */}
|
|
<button
|
|
onClick={() => setShowSiteManager(true)}
|
|
className="h-[46px] flex flex-col items-center justify-center px-3 gap-0.5 rounded bg-white hover:bg-slate-50 border border-slate-300 text-[10px] font-semibold text-slate-600 transition-colors shadow-sm"
|
|
title="사이트 관리자"
|
|
>
|
|
<BookOpen size={16} className="text-blue-600" />
|
|
<span>사이트</span>
|
|
</button>
|
|
|
|
<div className="w-px h-8 bg-slate-200 mx-1"></div>
|
|
|
|
{/* Quick Connect Inputs */}
|
|
<div className="flex flex-col gap-0.5">
|
|
<label className="text-[10px] text-slate-500 font-semibold pl-1">호스트</label>
|
|
<input
|
|
type="text"
|
|
value={connection.host}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
<label className="text-[10px] text-slate-500 font-semibold pl-1">사용자</label>
|
|
<input
|
|
type="text"
|
|
value={connection.user}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
<label className="text-[10px] text-slate-500 font-semibold pl-1">비밀번호</label>
|
|
<input
|
|
type="password"
|
|
value={connection.pass}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
<label className="text-[10px] text-slate-500 font-semibold pl-1">포트</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={connection.port}
|
|
onChange={(e) => 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 */}
|
|
<div className="flex flex-col items-center justify-center -mb-4">
|
|
<label className="relative inline-flex items-center cursor-pointer group" title="패시브 모드 (Passive Mode)">
|
|
<input
|
|
type="checkbox"
|
|
className="sr-only peer"
|
|
checked={connection.passive}
|
|
onChange={(e) => setConnection(c => ({ ...c, passive: e.target.checked }))}
|
|
/>
|
|
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
|
<span className="ml-1.5 text-[10px] font-medium text-slate-500 group-hover:text-slate-700">Passive</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1"></div>
|
|
|
|
{/* Connect & Options */}
|
|
<div className="flex items-end gap-2 pb-0.5">
|
|
<button
|
|
onClick={handleConnect}
|
|
disabled={connection.connecting}
|
|
className={`px-4 h-[30px] flex items-center justify-center gap-2 rounded font-semibold text-sm transition-all shadow-md ${connection.connected
|
|
? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100'
|
|
: 'bg-blue-600 text-white hover:bg-blue-500 shadow-blue-500/20'
|
|
}`}
|
|
>
|
|
{connection.connecting ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (connection.connected ? <><WifiOff size={16} /> 연결 해제</> : '빠른 연결')}
|
|
</button>
|
|
|
|
<div className="relative">
|
|
<button
|
|
onClick={handleDownloadBackend}
|
|
className={`h-[30px] flex items-center justify-center px-3 rounded bg-emerald-600 hover:bg-emerald-500 text-white border border-emerald-500 shadow-md shadow-emerald-500/20 transition-all ${!connection.connected ? 'animate-pulse ring-2 ring-emerald-400/50' : ''}`}
|
|
title="백엔드 코드(SFTP 지원) 다운로드"
|
|
>
|
|
<Download size={14} className="mr-1.5" /> 백엔드
|
|
</button>
|
|
{!connection.connected && (
|
|
<span className="absolute -top-1 -right-1 flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowSettings(true)}
|
|
className="w-[30px] h-[30px] flex items-center justify-center rounded bg-white border border-slate-300 text-slate-600 hover:text-slate-900 hover:bg-slate-50 shadow-sm"
|
|
title="설정"
|
|
>
|
|
<Settings size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 2. Log View & Sponsor Area */}
|
|
<div className="h-32 shrink-0 p-2 pb-0 flex gap-2">
|
|
<div className="w-1/2 min-w-0 h-full">
|
|
<LogConsole logs={logs} />
|
|
</div>
|
|
<div className="w-1/2 min-w-0 h-full bg-white border border-slate-300 rounded-lg shadow-sm flex flex-col items-center justify-center relative overflow-hidden group cursor-pointer">
|
|
<div className="absolute top-0 right-0 bg-slate-100 text-[9px] text-slate-500 px-1.5 py-0.5 rounded-bl border-b border-l border-slate-200 z-10">
|
|
SPONSORED
|
|
</div>
|
|
<div className="text-slate-300 flex flex-col items-center gap-1 group-hover:text-slate-400 transition-colors">
|
|
<div className="font-bold text-lg tracking-widest flex items-center gap-2">
|
|
<MousePointerClick size={20} /> GOOGLE ADSENSE
|
|
</div>
|
|
<div className="text-xs">Premium Ad Space Available</div>
|
|
</div>
|
|
{/* Pattern background for placeholder effect */}
|
|
<div className="absolute inset-0 -z-0 opacity-30" style={{ backgroundImage: 'radial-gradient(#cbd5e1 1px, transparent 1px)', backgroundSize: '10px 10px' }}></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3. Main Split View */}
|
|
<div className="flex-1 flex flex-col md:flex-row min-h-0 p-2 gap-2">
|
|
|
|
{/* Local Pane */}
|
|
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
|
|
<FilePane
|
|
title="로컬 사이트"
|
|
icon="local"
|
|
path={local.path}
|
|
files={local.files}
|
|
isLoading={local.isLoading}
|
|
onNavigate={handleLocalNavigate}
|
|
onNavigateUp={() => handleLocalNavigate(local.path.split('/').slice(0, -1).join('/') || '/')}
|
|
onSelectionChange={setSelectedLocalIds}
|
|
selectedIds={selectedLocalIds}
|
|
connected={true}
|
|
onCreateFolder={() => initiateCreateFolder(true)}
|
|
onDelete={() => initiateDelete(true)}
|
|
onRename={() => initiateRename(true)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Middle Actions */}
|
|
<div className="flex md:flex-col items-center justify-center gap-2 p-1">
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={selectedLocalIds.size === 0 || !connection.connected}
|
|
className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-slate-400 group relative"
|
|
>
|
|
<div className="absolute left-1/2 -top-8 -translate-x-1/2 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 shadow-lg">업로드</div>
|
|
<ArrowRight size={24} strokeWidth={2.5} />
|
|
</button>
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={selectedRemoteIds.size === 0 || !connection.connected}
|
|
className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-green-50 hover:text-green-600 hover:border-green-300 transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-slate-400 group relative"
|
|
>
|
|
<div className="absolute left-1/2 -top-8 -translate-x-1/2 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 shadow-lg">다운로드</div>
|
|
<ArrowLeft size={24} strokeWidth={2.5} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Remote Pane */}
|
|
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
|
|
<FilePane
|
|
title={`리모트 사이트: ${connection.host}`}
|
|
icon="remote"
|
|
path={remote.path}
|
|
files={remote.files}
|
|
isLoading={remote.isLoading}
|
|
onNavigate={handleRemoteNavigate}
|
|
onNavigateUp={() => handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')}
|
|
onSelectionChange={setSelectedRemoteIds}
|
|
selectedIds={selectedRemoteIds}
|
|
connected={connection.connected}
|
|
onCreateFolder={() => initiateCreateFolder(false)}
|
|
onDelete={() => initiateDelete(false)}
|
|
onRename={() => initiateRename(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. Queue / Status */}
|
|
<div className="h-48 shrink-0 p-2 pt-0">
|
|
<TransferQueue queue={queue} />
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<footer className="bg-white border-t border-slate-200 px-3 py-1 text-[10px] text-slate-500 flex justify-between shadow-[0_-2px_10px_rgba(0,0,0,0.02)]">
|
|
<span>WebZilla v1.1.0 - React/TypeScript Demo</span>
|
|
<div className="flex gap-4">
|
|
<span>Server: {connection.connected ? 'Connected' : 'Disconnected'}</span>
|
|
<span>Protocol: {connection.protocol.toUpperCase()} {connection.passive ? '(Passive)' : ''}</span>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App; |