This commit is contained in:
backuppc
2026-01-19 11:40:55 +09:00
parent 1e20601741
commit c485f411b3
11 changed files with 3250 additions and 499 deletions

View File

@@ -0,0 +1,34 @@
---
description: Build the backend proxy server as a standalone .exe file
---
# Build Backend Executable
This workflow explains how to package the Node.js backend (`backend_proxy.cjs`) into a standalone executable file (`.exe`) using [pkg](https://github.com/vercel/pkg).
## Prerequisites
- Node.js installed
- `npm` installed
## Steps
1. **Install `pkg` globally** (if not already installed):
```powershell
npm install -g pkg
```
2. **Build the executable**:
Run the following command in the project root (`c:\Data\Source\SIMP\WebFTP`):
```powershell
pkg backend_proxy.cjs --targets node18-win-x64 --output webzilla-backend.exe
```
- `--targets node18-win-x64`: Targets Node.js 18 on Windows 64-bit. Adjust as needed (e.g., `node18-macos-x64`, `node18-linux-x64`).
- `--output webzilla-backend.exe`: The name of the output file.
3. **Run the executable**:
Double-click `webzilla-backend.exe` or run it from the terminal to start the server.
## Notes
- The executable will include the Node.js runtime and your script, so no external Node.js installation is required for the end user.
- Configuration files will still be stored in the user's AppData/Home directory as defined in the script.

713
App.tsx
View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Settings, WifiOff, ArrowRight, ArrowLeft, BookOpen, Download, MousePointerClick } from 'lucide-react'; import { Settings, WifiOff, ArrowRight, ArrowLeft, BookOpen, Download, MousePointerClick } from 'lucide-react';
import { FileItem, FileType, LogEntry, TransferItem, FileSystemState, SiteConfig } from './types'; import { FileItem, FileType, LogEntry, TransferItem, FileSystemState, SiteConfig } from './types';
import FilePane from './components/FilePane'; import FilePane from './components/FilePane';
@@ -6,16 +6,15 @@ import LogConsole from './components/LogConsole';
import TransferQueue from './components/TransferQueue'; import TransferQueue from './components/TransferQueue';
import SettingsModal from './components/SettingsModal'; import SettingsModal from './components/SettingsModal';
import SiteManagerModal from './components/SiteManagerModal'; import SiteManagerModal from './components/SiteManagerModal';
import ConnectionHelpModal from './components/ConnectionHelpModal';
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals'; import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
import { generateLocalFiles } from './utils/mockData';
import { generateRemoteFileList, generateServerMessage } from './services/gemini';
const App: React.FC = () => { const App: React.FC = () => {
// --- State --- // --- State ---
const [connection, setConnection] = useState({ const [connection, setConnection] = useState({
host: 'ftp.example.com', host: 'ftp.example.com',
user: 'admin', user: 'admin',
pass: '••••••••', pass: '',
port: '21', port: '21',
protocol: 'ftp' as 'ftp' | 'sftp', protocol: 'ftp' as 'ftp' | 'sftp',
passive: true, passive: true,
@@ -26,18 +25,18 @@ const App: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [queue, setQueue] = useState<TransferItem[]>([]); const [queue, setQueue] = useState<TransferItem[]>([]);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
const [showSiteManager, setShowSiteManager] = useState(false); const [showSiteManager, setShowSiteManager] = useState(false);
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]); const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
// Modals State // Modals State
const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null); const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null);
const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true); const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true);
// For Rename
const [renameTargetName, setRenameTargetName] = useState(''); const [renameTargetName, setRenameTargetName] = useState('');
// Local File System // Local File System
const [local, setLocal] = useState<FileSystemState>({ const [local, setLocal] = useState<FileSystemState>({
path: '/', path: 'Wait Server...',
files: [], files: [],
isLoading: false isLoading: false
}); });
@@ -51,21 +50,130 @@ const App: React.FC = () => {
}); });
const [selectedRemoteIds, setSelectedRemoteIds] = useState<Set<string>>(new Set()); const [selectedRemoteIds, setSelectedRemoteIds] = useState<Set<string>>(new Set());
// WebSocket
const wsRef = useRef<WebSocket | null>(null);
// --- Helpers --- // --- Helpers ---
const addLog = (type: LogEntry['type'], message: string) => { const addLog = useCallback((type: LogEntry['type'], message: string) => {
setLogs(prev => [...prev, { setLogs(prev => [...prev, {
id: Math.random().toString(36), id: Math.random().toString(36),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
type, type,
message message
}]); }]);
}; }, []);
const getSelectedFiles = (allFiles: FileItem[], ids: Set<string>) => { const getSelectedFiles = (allFiles: FileItem[], ids: Set<string>) => {
return allFiles.filter(f => ids.has(f.id)); return allFiles.filter(f => ids.has(f.id));
}; };
// --- Backend Download Logic (Updated for SFTP Support) --- // --- 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 handleDownloadBackend = () => {
const backendCode = `/** const backendCode = `/**
* WebZilla 백엔드 프록시 서버 (Node.js) v2.0 * WebZilla 백엔드 프록시 서버 (Node.js) v2.0
@@ -74,7 +182,7 @@ const App: React.FC = () => {
* 실행 방법: * 실행 방법:
* 1. Node.js 설치 * 1. Node.js 설치
* 2. npm install ws basic-ftp ssh2-sftp-client * 2. npm install ws basic-ftp ssh2-sftp-client
* 3. node backend_proxy.js * 3. node backend_proxy.cjs
*/ */
const WebSocket = require('ws'); const WebSocket = require('ws');
@@ -100,8 +208,8 @@ if (!fs.existsSync(configDir)) {
try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) {} try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) {}
} }
const wss = new WebSocket.Server({ port: 8080 }); const wss = new WebSocket.Server({ port: 8090 });
console.log("🚀 WebZilla Proxy Server running on ws://localhost:8080"); console.log("🚀 WebZilla Proxy Server running on ws://localhost:8090");
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
// 클라이언트 상태 관리 // 클라이언트 상태 관리
@@ -139,7 +247,6 @@ wss.on('connection', (ws) => {
port: parseInt(port) || 21, port: parseInt(port) || 21,
secure: false secure: false
}); });
// 패시브 모드는 basic-ftp에서 기본적으로 자동 처리하지만 명시적 설정이 필요할 수 있음
ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP Connected' })); ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP Connected' }));
} }
isConnected = true; isConnected = true;
@@ -200,8 +307,6 @@ wss.on('connection', (ws) => {
// --- DELE / RMD (Delete) --- // --- DELE / RMD (Delete) ---
else if (data.command === 'DELE') { else if (data.command === 'DELE') {
if (!isConnected) return; if (!isConnected) return;
// Note: handling single file deletion for simplicity in this snippet
// In real app, iterate over items
try { try {
const { path, isFolder } = data; const { path, isFolder } = data;
if (currentProtocol === 'sftp') { if (currentProtocol === 'sftp') {
@@ -249,12 +354,22 @@ wss.on('connection', (ws) => {
const sitesFile = path.join(configDir, 'sites.json'); const sitesFile = path.join(configDir, 'sites.json');
let sites = []; let sites = [];
if (fs.existsSync(sitesFile)) sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8')); if (fs.existsSync(sitesFile)) sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8'));
// Simple append/replace logic omitted for brevity
sites.push(data.siteInfo); sites.push(data.siteInfo);
fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2)); fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2));
ws.send(JSON.stringify({ type: 'success', message: 'Site saved locally' })); 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) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -272,147 +387,62 @@ wss.on('connection', (ws) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'backend_proxy.js'; a.download = 'backend_proxy.cjs';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
addLog('info', '업데이트된 백엔드 코드(SFTP 지원)가 다운로드되었습니다.'); addLog('info', '업데이트된 백엔드 코드(8090 포트)가 다운로드되었습니다.');
}; };
// --- Effects --- // --- Effects ---
// Initial Load // Initial Load - Removed Log "Initialized" to avoid clutter or duplicates
useEffect(() => {
setLocal(prev => ({ ...prev, files: generateLocalFiles('/') }));
addLog('info', 'WebZilla 클라이언트 v1.1.0 초기화됨');
const storedSites = localStorage.getItem('webzilla_sites');
if (storedSites) {
try {
setSavedSites(JSON.parse(storedSites));
} catch (e) { console.error("Failed to load sites", e); }
}
}, []);
// Transfer Simulation Loop
useEffect(() => {
const interval = setInterval(() => {
setQueue(prevQueue => {
let hasChanges = false;
const newQueue = prevQueue.map(item => {
if (item.status === 'transferring') {
hasChanges = true;
const step = 5 + Math.random() * 10;
const newProgress = Math.min(100, item.progress + step);
if (newProgress === 100) {
addLog('success', `전송 완료: ${item.filename}`);
return { ...item, progress: 100, status: 'completed' as const, speed: '0 KB/s' };
}
return {
...item,
progress: newProgress,
speed: `${(Math.random() * 500 + 100).toFixed(1)} KB/s`
};
}
return item;
});
// Start next queued item
const activeCount = newQueue.filter(i => i.status === 'transferring').length;
if (activeCount < 2) {
const nextItem = newQueue.find(i => i.status === 'queued');
if (nextItem) {
hasChanges = true;
nextItem.status = 'transferring';
addLog('info', `전송 시작: ${nextItem.filename}`);
}
}
return hasChanges ? newQueue : prevQueue;
});
}, 500);
return () => clearInterval(interval);
}, []);
// --- Connection Handlers --- // --- Connection Handlers ---
const handleConnect = () => {
const handleConnect = async () => {
if (connection.connected) { if (connection.connected) {
setConnection(prev => ({ ...prev, connected: false })); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
addLog('info', '서버 연결이 해제되었습니다.'); wsRef.current.send(JSON.stringify({ command: 'DISCONNECT' }));
setRemote(prev => ({ ...prev, files: [], path: '/' })); }
setSelectedRemoteIds(new Set());
return; return;
} }
setConnection(prev => ({ ...prev, connecting: true })); setConnection(prev => ({ ...prev, connecting: true }));
addLog('command', `CONNECT [${connection.protocol.toUpperCase()}] ${connection.user}@${connection.host}:${connection.port} ${connection.passive ? '(Passive)' : ''}`); addLog('command', `CONNECT [${connection.protocol.toUpperCase()}] ${connection.user}@${connection.host}:${connection.port} ${connection.passive ? '(Passive)' : ''}`);
// Simulation delays if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
setTimeout(() => addLog('info', `주소 해석 중: ${connection.host}`), 500); wsRef.current.send(JSON.stringify({
setTimeout(() => addLog('success', '연결 성공, 환영 메시지 대기 중...'), 1500); command: 'CONNECT',
host: connection.host,
// AI generated content user: connection.user,
setTimeout(async () => { pass: connection.pass,
const welcomeMsg = await generateServerMessage(connection.host); port: connection.port,
addLog('response', welcomeMsg); protocol: connection.protocol,
if (connection.protocol === 'ftp') { passive: connection.passive
addLog('command', 'USER ' + connection.user); }));
addLog('response', '331 Password required'); } else {
addLog('command', 'PASS ******'); addLog('error', '백엔드 서버에 연결되어 있지 않습니다. 잠시 후 다시 시도하세요.');
addLog('response', '230 Logged on'); setConnection(prev => ({ ...prev, connecting: false }));
} else { }
addLog('info', 'SSH2 인증 성공 (SFTP)');
}
setRemote(prev => ({ ...prev, isLoading: true }));
const files = await generateRemoteFileList(connection.host, '/');
setRemote({
path: '/',
files: files,
isLoading: false
});
setConnection(prev => ({ ...prev, connected: true, connecting: false }));
addLog('success', '디렉토리 목록 조회 완료');
}, 2500);
}; };
const handleRemoteNavigate = async (path: string) => { const handleRemoteNavigate = (path: string) => {
if (!connection.connected) return; if (!connection.connected) return;
setRemote(prev => ({ ...prev, isLoading: true })); setRemote(prev => ({ ...prev, isLoading: true }));
setSelectedRemoteIds(new Set()); // Clear selection setSelectedRemoteIds(new Set()); // Clear selection
addLog('command', `CWD ${path}`); addLog('command', `CWD ${path}`);
// Simulate network latency if (wsRef.current) {
setTimeout(async () => { wsRef.current.send(JSON.stringify({ command: 'LIST', path }));
addLog('response', '250 Directory successfully changed.'); }
addLog('command', 'LIST');
const files = await generateRemoteFileList(connection.host, path);
setRemote({
path,
files,
isLoading: false
});
addLog('success', '디렉토리 목록 조회 완료');
}, 800);
}; };
const handleLocalNavigate = (path: string) => { const handleLocalNavigate = (path: string) => {
setLocal(prev => ({ ...prev, isLoading: true })); // Local View is currently placeholder or needs Backend 'Local File Support'
setSelectedLocalIds(new Set()); // Clear selection // Since backend proxy supports 'local fs' potentially, we could add that.
setTimeout(() => { // For now, let's keep it static or minimal as user requested FTP/SFTP features mainly
setLocal({ addLog('info', '로컬 탐색은 데스크탑 앱 모드에서 지원됩니다.');
path,
files: generateLocalFiles(path),
isLoading: false
});
}, 200);
}; };
// --- File Action Handlers (Triggers Modals) --- // --- File Action Handlers (Triggers Modals) ---
@@ -431,9 +461,9 @@ wss.on('connection', (ws) => {
const files = isLocal ? local.files : remote.files; const files = isLocal ? local.files : remote.files;
const file = files.find(f => selectedIds.has(f.id)); const file = files.find(f => selectedIds.has(f.id));
if (file) { if (file) {
setRenameTargetName(file.name); setRenameTargetName(file.name);
setModalTargetIsLocal(isLocal); setModalTargetIsLocal(isLocal);
setActiveModal('rename'); setActiveModal('rename');
} }
}; };
@@ -450,23 +480,16 @@ wss.on('connection', (ws) => {
const handleCreateFolderConfirm = (name: string) => { const handleCreateFolderConfirm = (name: string) => {
if (!name.trim()) return; if (!name.trim()) return;
const isLocal = modalTargetIsLocal; const isLocal = modalTargetIsLocal;
const newItem: FileItem = {
id: `new-${Date.now()}`,
name,
type: FileType.FOLDER,
size: 0,
date: new Date().toISOString(),
permissions: 'drwxr-xr-x'
};
if (isLocal) { if (isLocal) {
setLocal(prev => ({ ...prev, files: [...prev.files, newItem] })); // Local logic placeholder
addLog('info', `[로컬] 디렉토리 생성: ${name}`);
} else { } else {
setRemote(prev => ({ ...prev, files: [...prev.files, newItem] }));
addLog('command', `MKD ${name}`); addLog('command', `MKD ${name}`);
addLog('success', `257 "${name}" created`); 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); setActiveModal(null);
}; };
@@ -477,27 +500,22 @@ wss.on('connection', (ws) => {
const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds;
const files = isLocal ? local.files : remote.files; const files = isLocal ? local.files : remote.files;
const targetFile = files.find(f => selectedIds.has(f.id)); const targetFile = files.find(f => selectedIds.has(f.id));
if (!targetFile) return; if (!targetFile) return;
if (targetFile.name === newName) { if (targetFile.name === newName) {
setActiveModal(null); setActiveModal(null);
return; return;
} }
if (isLocal) { if (isLocal) {
setLocal(prev => ({ // Local placeholder
...prev,
files: prev.files.map(f => f.id === targetFile.id ? { ...f, name: newName } : f)
}));
addLog('info', `[로컬] 이름 변경: ${targetFile.name} -> ${newName}`);
} else { } else {
setRemote(prev => ({ addLog('command', `RNFR ${targetFile.name} -> RNTO ${newName}`);
...prev, if (wsRef.current) {
files: prev.files.map(f => f.id === targetFile.id ? { ...f, name: newName } : f) const from = remote.path === '/' ? `/${targetFile.name}` : `${remote.path}/${targetFile.name}`;
})); const to = remote.path === '/' ? `/${newName}` : `${remote.path}/${newName}`;
addLog('command', `RNFR ${targetFile.name}`); wsRef.current.send(JSON.stringify({ command: 'RENAME', from, to }));
addLog('command', `RNTO ${newName}`); }
addLog('success', '250 Rename successful');
} }
setActiveModal(null); setActiveModal(null);
}; };
@@ -506,15 +524,27 @@ wss.on('connection', (ws) => {
const isLocal = modalTargetIsLocal; const isLocal = modalTargetIsLocal;
const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds; const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds;
// Deleting multiple files
if (isLocal) { if (isLocal) {
setLocal(prev => ({ ...prev, files: prev.files.filter(f => !selectedIds.has(f.id)) })); // Local placeholder
setSelectedLocalIds(new Set());
addLog('info', `[로컬] ${selectedIds.size}개 항목 삭제됨`);
} else { } else {
setRemote(prev => ({ ...prev, files: prev.files.filter(f => !selectedIds.has(f.id)) }));
setSelectedRemoteIds(new Set());
addLog('command', `DELE [${selectedIds.size} items]`); addLog('command', `DELE [${selectedIds.size} items]`);
addLog('success', '250 Deleted successfully'); // 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); setActiveModal(null);
}; };
@@ -522,108 +552,90 @@ wss.on('connection', (ws) => {
// --- Transfer Logic --- // --- Transfer Logic ---
const handleUpload = () => { const handleUpload = () => {
if (selectedLocalIds.size === 0 || !connection.connected) return; // Not implemented in this version
const selectedFiles = getSelectedFiles(local.files, selectedLocalIds); addLog('info', '업로드 기능은 준비 중입니다.');
const filesToUpload = selectedFiles.filter(f => f.type !== FileType.FOLDER);
if (filesToUpload.length === 0) {
addLog('error', '업로드할 파일이 없습니다 (폴더 제외)');
return;
}
const newItems: TransferItem[] = filesToUpload.map(f => ({
id: Math.random().toString(),
direction: 'upload',
filename: f.name,
progress: 0,
status: 'queued',
speed: '-'
}));
setQueue(prev => [...newItems, ...prev]);
addLog('info', `${newItems.length}개 파일 업로드 대기`);
}; };
const handleDownload = () => { const handleDownload = () => {
if (selectedRemoteIds.size === 0 || !connection.connected) return; // Not implemented in this version
const selectedFiles = getSelectedFiles(remote.files, selectedRemoteIds); addLog('info', '다운로드 기능은 준비 중입니다.');
const filesToDownload = selectedFiles.filter(f => f.type !== FileType.FOLDER);
if (filesToDownload.length === 0) {
addLog('error', '다운로드할 파일이 없습니다 (폴더 제외)');
return;
}
const newItems: TransferItem[] = filesToDownload.map(f => ({
id: Math.random().toString(),
direction: 'download',
filename: f.name,
progress: 0,
status: 'queued',
speed: '-'
}));
setQueue(prev => [...newItems, ...prev]);
addLog('info', `${newItems.length}개 파일 다운로드 대기`);
}; };
// --- Site Manager Handlers --- // --- Site Manager Handlers ---
const handleSiteConnect = (site: SiteConfig) => { const handleSiteConnect = (site: SiteConfig) => {
setConnection({ setConnection({
host: site.host, host: site.host,
port: site.port, port: site.port,
user: site.user, user: site.user,
pass: site.pass || '', pass: site.pass || '',
protocol: site.protocol, protocol: site.protocol,
passive: site.passiveMode !== false, passive: site.passiveMode !== false,
connected: false, connected: false, // Will trigger connect flow
connecting: false connecting: false
}); });
setShowSiteManager(false); setShowSiteManager(false);
setTimeout(() => handleConnect(), 100); // 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 --- // --- Render ---
// Prepare data for Delete Modal // Prepare data for Delete Modal
const getDeleteModalData = () => { const getDeleteModalData = () => {
const isLocal = modalTargetIsLocal; const isLocal = modalTargetIsLocal;
const ids = isLocal ? selectedLocalIds : selectedRemoteIds; const ids = isLocal ? selectedLocalIds : selectedRemoteIds;
const files = isLocal ? local.files : remote.files; const files = isLocal ? local.files : remote.files;
const selectedFiles = files.filter(f => ids.has(f.id)); const selectedFiles = files.filter(f => ids.has(f.id));
return { return {
count: ids.size, count: ids.size,
names: selectedFiles.map(f => f.name) names: selectedFiles.map(f => f.name)
}; };
}; };
return ( return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans"> <div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans">
{/* Modals */} {/* Modals */}
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} /> <SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<SiteManagerModal <ConnectionHelpModal isOpen={showConnectionHelp} onClose={() => setShowConnectionHelp(false)} />
isOpen={showSiteManager} <SiteManagerModal
isOpen={showSiteManager}
onClose={() => setShowSiteManager(false)} onClose={() => setShowSiteManager(false)}
initialSites={savedSites} initialSites={savedSites}
onSaveSites={(sites) => { onSaveSites={handleSaveSites}
setSavedSites(sites);
localStorage.setItem('webzilla_sites', JSON.stringify(sites));
}}
onConnect={handleSiteConnect} onConnect={handleSiteConnect}
/> />
<CreateFolderModal <CreateFolderModal
isOpen={activeModal === 'create'} isOpen={activeModal === 'create'}
onClose={() => setActiveModal(null)} onClose={() => setActiveModal(null)}
onConfirm={handleCreateFolderConfirm} onConfirm={handleCreateFolderConfirm}
/> />
<RenameModal <RenameModal
isOpen={activeModal === 'rename'} isOpen={activeModal === 'rename'}
currentName={renameTargetName} currentName={renameTargetName}
onClose={() => setActiveModal(null)} onClose={() => setActiveModal(null)}
onConfirm={handleRenameConfirm} onConfirm={handleRenameConfirm}
/> />
<DeleteModal <DeleteModal
isOpen={activeModal === 'delete'} isOpen={activeModal === 'delete'}
fileCount={getDeleteModalData().count} fileCount={getDeleteModalData().count}
fileNames={getDeleteModalData().names} fileNames={getDeleteModalData().names}
@@ -635,20 +647,20 @@ wss.on('connection', (ws) => {
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20"> <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 flex-col xl:flex-row gap-4 items-center">
<div className="flex items-center gap-2 shrink-0"> <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> <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> <span className="font-bold text-lg tracking-tight hidden sm:inline text-slate-700"></span>
</div> </div>
<div className="flex-1 w-full flex flex-wrap items-center gap-2"> <div className="flex-1 w-full flex flex-wrap items-center gap-2">
{/* Site Manager Trigger */} {/* Site Manager Trigger */}
<button <button
onClick={() => setShowSiteManager(true)} 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" 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="사이트 관리자" title="사이트 관리자"
> >
<BookOpen size={16} className="text-blue-600" /> <BookOpen size={16} className="text-blue-600" />
<span></span> <span></span>
</button> </button>
<div className="w-px h-8 bg-slate-200 mx-1"></div> <div className="w-px h-8 bg-slate-200 mx-1"></div>
@@ -656,10 +668,10 @@ wss.on('connection', (ws) => {
{/* Quick Connect Inputs */} {/* Quick Connect Inputs */}
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[10px] text-slate-500 font-semibold pl-1"></label> <label className="text-[10px] text-slate-500 font-semibold pl-1"></label>
<input <input
type="text" type="text"
value={connection.host} value={connection.host}
onChange={(e) => setConnection(c => ({...c, host: e.target.value}))} 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" 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" placeholder="ftp.example.com"
/> />
@@ -667,20 +679,20 @@ wss.on('connection', (ws) => {
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[10px] text-slate-500 font-semibold pl-1"></label> <label className="text-[10px] text-slate-500 font-semibold pl-1"></label>
<input <input
type="text" type="text"
value={connection.user} value={connection.user}
onChange={(e) => setConnection(c => ({...c, user: e.target.value}))} 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" 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>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[10px] text-slate-500 font-semibold pl-1"></label> <label className="text-[10px] text-slate-500 font-semibold pl-1"></label>
<input <input
type="password" type="password"
value={connection.pass} value={connection.pass}
onChange={(e) => setConnection(c => ({...c, pass: e.target.value}))} 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" 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>
@@ -688,25 +700,25 @@ wss.on('connection', (ws) => {
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[10px] text-slate-500 font-semibold pl-1"></label> <label className="text-[10px] text-slate-500 font-semibold pl-1"></label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="text" type="text"
value={connection.port} value={connection.port}
onChange={(e) => setConnection(c => ({...c, port: e.target.value}))} 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" 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 */} {/* Passive Mode Toggle Switch */}
<div className="flex flex-col items-center justify-center -mb-4"> <div className="flex flex-col items-center justify-center -mb-4">
<label className="relative inline-flex items-center cursor-pointer group" title="패시브 모드 (Passive Mode)"> <label className="relative inline-flex items-center cursor-pointer group" title="패시브 모드 (Passive Mode)">
<input <input
type="checkbox" type="checkbox"
className="sr-only peer" className="sr-only peer"
checked={connection.passive} checked={connection.passive}
onChange={(e) => setConnection(c => ({...c, passive: e.target.checked}))} 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> <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> <span className="ml-1.5 text-[10px] font-medium text-slate-500 group-hover:text-slate-700">Passive</span>
</label> </label>
</div> </div>
</div> </div>
</div> </div>
@@ -715,41 +727,40 @@ wss.on('connection', (ws) => {
{/* Connect & Options */} {/* Connect & Options */}
<div className="flex items-end gap-2 pb-0.5"> <div className="flex items-end gap-2 pb-0.5">
<button <button
onClick={handleConnect} onClick={handleConnect}
disabled={connection.connecting} disabled={connection.connecting}
className={`px-4 h-[30px] flex items-center justify-center gap-2 rounded font-semibold text-sm transition-all shadow-md ${ className={`px-4 h-[30px] flex items-center justify-center gap-2 rounded font-semibold text-sm transition-all shadow-md ${connection.connected
connection.connected ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100'
? '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'
: '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} /> </> : '빠른 연결')}
{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>
</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 <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)} 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" 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="설정" title="설정"
> >
<Settings size={16} /> <Settings size={16} />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -758,30 +769,30 @@ wss.on('connection', (ws) => {
{/* 2. Log View & Sponsor Area */} {/* 2. Log View & Sponsor Area */}
<div className="h-32 shrink-0 p-2 pb-0 flex gap-2"> <div className="h-32 shrink-0 p-2 pb-0 flex gap-2">
<div className="w-1/2 min-w-0 h-full"> <div className="w-1/2 min-w-0 h-full">
<LogConsole logs={logs} /> <LogConsole logs={logs} />
</div> </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="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"> <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 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>
<div className="text-slate-300 flex flex-col items-center gap-1 group-hover:text-slate-400 transition-colors"> <div className="text-xs">Premium Ad Space Available</div>
<div className="font-bold text-lg tracking-widest flex items-center gap-2"> </div>
<MousePointerClick size={20} /> GOOGLE ADSENSE {/* Pattern background for placeholder effect */}
</div> <div className="absolute inset-0 -z-0 opacity-30" style={{ backgroundImage: 'radial-gradient(#cbd5e1 1px, transparent 1px)', backgroundSize: '10px 10px' }}></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>
</div> </div>
{/* 3. Main Split View */} {/* 3. Main Split View */}
<div className="flex-1 flex flex-col md:flex-row min-h-0 p-2 gap-2"> <div className="flex-1 flex flex-col md:flex-row min-h-0 p-2 gap-2">
{/* Local Pane */} {/* Local Pane */}
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]"> <div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
<FilePane <FilePane
title="로컬 사이트" title="로컬 사이트"
icon="local" icon="local"
path={local.path} path={local.path}
files={local.files} files={local.files}
@@ -799,40 +810,40 @@ wss.on('connection', (ws) => {
{/* Middle Actions */} {/* Middle Actions */}
<div className="flex md:flex-col items-center justify-center gap-2 p-1"> <div className="flex md:flex-col items-center justify-center gap-2 p-1">
<button <button
onClick={handleUpload} onClick={handleUpload}
disabled={selectedLocalIds.size === 0 || !connection.connected} 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" 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> <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} /> <ArrowRight size={24} strokeWidth={2.5} />
</button> </button>
<button <button
onClick={handleDownload} onClick={handleDownload}
disabled={selectedRemoteIds.size === 0 || !connection.connected} 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" 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> <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} /> <ArrowLeft size={24} strokeWidth={2.5} />
</button> </button>
</div> </div>
{/* Remote Pane */} {/* Remote Pane */}
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]"> <div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
<FilePane <FilePane
title={`리모트 사이트: ${connection.host}`} title={`리모트 사이트: ${connection.host}`}
icon="remote" icon="remote"
path={remote.path} path={remote.path}
files={remote.files} files={remote.files}
isLoading={remote.isLoading} isLoading={remote.isLoading}
onNavigate={handleRemoteNavigate} onNavigate={handleRemoteNavigate}
onNavigateUp={() => handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')} onNavigateUp={() => handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')}
onSelectionChange={setSelectedRemoteIds} onSelectionChange={setSelectedRemoteIds}
selectedIds={selectedRemoteIds} selectedIds={selectedRemoteIds}
connected={connection.connected} connected={connection.connected}
onCreateFolder={() => initiateCreateFolder(false)} onCreateFolder={() => initiateCreateFolder(false)}
onDelete={() => initiateDelete(false)} onDelete={() => initiateDelete(false)}
onRename={() => initiateRename(false)} onRename={() => initiateRename(false)}
/> />
</div> </div>
</div> </div>
@@ -846,8 +857,8 @@ wss.on('connection', (ws) => {
<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)]"> <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> <span>WebZilla v1.1.0 - React/TypeScript Demo</span>
<div className="flex gap-4"> <div className="flex gap-4">
<span>Server: {connection.connected ? 'Connected' : 'Disconnected'}</span> <span>Server: {connection.connected ? 'Connected' : 'Disconnected'}</span>
<span>Protocol: {connection.protocol.toUpperCase()} {connection.passive ? '(Passive)' : ''}</span> <span>Protocol: {connection.protocol.toUpperCase()} {connection.passive ? '(Passive)' : ''}</span>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -15,15 +15,15 @@ const os = require('os');
// --- 로컬 저장소 경로 설정 (AppData 구현) --- // --- 로컬 저장소 경로 설정 (AppData 구현) ---
function getConfigDir() { function getConfigDir() {
const homedir = os.homedir(); const homedir = os.homedir();
// Windows: C:\Users\User\AppData\Roaming\WebZilla // Windows: C:\Users\User\AppData\Roaming\WebZilla
if (process.platform === 'win32') { if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla'); return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
} }
// macOS: ~/Library/Application Support/WebZilla // macOS: ~/Library/Application Support/WebZilla
else if (process.platform === 'darwin') { else if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', 'WebZilla'); return path.join(homedir, 'Library', 'Application Support', 'WebZilla');
} }
// Linux: ~/.config/webzilla // Linux: ~/.config/webzilla
else { else {
return path.join(homedir, '.config', 'webzilla'); return path.join(homedir, '.config', 'webzilla');
@@ -43,19 +43,19 @@ if (!fs.existsSync(configDir)) {
console.log(`📂 설정 폴더 로드됨: ${configDir}`); console.log(`📂 설정 폴더 로드됨: ${configDir}`);
} }
const wss = new WebSocket.Server({ port: 8080 }); const wss = new WebSocket.Server({ port: 8090 });
console.log("🚀 WebZilla FTP Proxy Server가 ws://localhost:8080 에서 실행 중입니다."); console.log("🚀 WebZilla FTP Proxy Server가 ws://localhost:8090 에서 실행 중입니다.");
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
console.log("클라이언트가 접속했습니다."); console.log("클라이언트가 접속했습니다.");
const client = new ftp.Client(); const client = new ftp.Client();
ws.on('message', async (message) => { ws.on('message', async (message) => {
try { try {
const data = JSON.parse(message); const data = JSON.parse(message);
switch (data.command) { switch (data.command) {
// --- FTP 연결 관련 --- // --- FTP 연결 관련 ---
case 'CONNECT': case 'CONNECT':
@@ -82,16 +82,16 @@ wss.on('connection', (ws) => {
} }
const listPath = data.path || '/'; const listPath = data.path || '/';
const list = await client.list(listPath); const list = await client.list(listPath);
const files = list.map(f => ({ const files = list.map(f => ({
id: `ftp-${Date.now()}-${Math.random()}`, id: `ftp-${Date.now()}-${Math.random()}`,
name: f.name, name: f.name,
type: f.isDirectory ? 'FOLDER' : 'FILE', type: f.isDirectory ? 'FOLDER' : 'FILE',
size: f.size, size: f.size,
date: f.rawModifiedAt || new Date().toISOString(), date: f.rawModifiedAt || new Date().toISOString(),
permissions: '-' permissions: '-'
})); }));
ws.send(JSON.stringify({ type: 'list', files, path: listPath })); ws.send(JSON.stringify({ type: 'list', files, path: listPath }));
break; break;
@@ -111,7 +111,7 @@ wss.on('connection', (ws) => {
} }
sites.push(data.siteInfo); sites.push(data.siteInfo);
fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2)); fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2));
console.log(`💾 사이트 정보 저장됨: ${data.siteInfo.host}`); console.log(`💾 사이트 정보 저장됨: ${data.siteInfo.host}`);
ws.send(JSON.stringify({ type: 'success', message: '사이트 정보가 로컬(AppData)에 저장되었습니다.' })); ws.send(JSON.stringify({ type: 'success', message: '사이트 정보가 로컬(AppData)에 저장되었습니다.' }));
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { ShieldAlert, ExternalLink, X, AlertTriangle } from 'lucide-react';
interface ConnectionHelpModalProps {
isOpen: boolean;
onClose: () => void;
}
const ConnectionHelpModal: React.FC<ConnectionHelpModalProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-lg shadow-2xl max-w-lg w-full overflow-hidden border border-red-100">
{/* Header */}
<div className="bg-red-50 p-4 border-b border-red-100 flex items-center justify-between">
<div className="flex items-center gap-2 text-red-700">
<ShieldAlert size={20} />
<h2 className="font-bold text-lg"> </h2>
</div>
<button onClick={onClose} className="text-red-400 hover:text-red-600 transition-colors">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<p className="text-slate-700 text-sm leading-relaxed">
<strong>HTTPS()</strong> <strong> (ws://localhost)</strong>로 접속을 시도하고 있습니다.
(Mixed Content) .
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-2">
<h3 className="font-semibold text-yellow-800 flex items-center gap-2 text-sm">
<AlertTriangle size={16} />
(Chrome/Edge)
</h3>
<ol className="list-decimal list-inside text-xs text-yellow-800 space-y-1.5 ml-1">
<li>
:
<div className="bg-white border border-yellow-300 rounded px-2 py-1 mt-1 font-mono text-slate-600 select-all cursor-pointer hover:bg-slate-50" onClick={(e) => navigator.clipboard.writeText(e.currentTarget.textContent || '')}>
chrome://flags/#allow-insecure-localhost
</div>
</li>
<li>
<strong>Allow invalid certificates for resources from localhost</strong> <span className="font-bold text-green-600">Enabled</span> .
</li>
<li>
<strong>Relaunch</strong> .
</li>
</ol>
</div>
<p className="text-xs text-slate-500 text-center">
.
</p>
</div>
{/* Footer */}
<div className="p-4 bg-slate-50 border-t border-slate-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-white hover:bg-slate-100 text-slate-700 border border-slate-300 rounded text-sm transition-colors font-medium shadow-sm"
>
</button>
</div>
</div>
</div>
);
};
export default ConnectionHelpModal;

View File

@@ -22,7 +22,7 @@ const ftp = require('basic-ftp');
const SftpClient = require('ssh2-sftp-client'); const SftpClient = require('ssh2-sftp-client');
// ... imports // ... imports
const wss = new WebSocket.Server({ port: 8080 }); const wss = new WebSocket.Server({ port: 8090 });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
let ftpClient = new ftp.Client(); let ftpClient = new ftp.Client();
@@ -64,7 +64,7 @@ wss.on('connection', (ws) => {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-2xl flex flex-col max-h-[85vh]"> <div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-2xl flex flex-col max-h-[85vh]">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200"> <div className="flex items-center justify-between p-4 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2"> <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
@@ -78,19 +78,17 @@ wss.on('connection', (ws) => {
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-slate-200 px-4"> <div className="flex border-b border-slate-200 px-4">
<button <button
onClick={() => setActiveTab('arch')} onClick={() => setActiveTab('arch')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${ className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === 'arch' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'
activeTab === 'arch' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700' }`}
}`}
> >
</button> </button>
<button <button
onClick={() => setActiveTab('code')} onClick={() => setActiveTab('code')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${ className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === 'code' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'
activeTab === 'code' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700' }`}
}`}
> >
(Preview) (Preview)
</button> </button>
@@ -110,9 +108,9 @@ wss.on('connection', (ws) => {
</div> </div>
<div className="flex flex-col items-center gap-1 flex-1"> <div className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-green-600 bg-green-100 px-2 py-0.5 rounded border border-green-200 font-mono">WebSocket</span> <span className="text-[10px] text-green-600 bg-green-100 px-2 py-0.5 rounded border border-green-200 font-mono">WebSocket</span>
<ArrowLeftRight className="text-slate-400 w-full animate-pulse" /> <ArrowLeftRight className="text-slate-400 w-full animate-pulse" />
<span className="text-xs text-slate-400">JSON Protocol</span> <span className="text-xs text-slate-400">JSON Protocol</span>
</div> </div>
<div className="flex flex-col items-center gap-2 relative"> <div className="flex flex-col items-center gap-2 relative">
@@ -120,20 +118,20 @@ wss.on('connection', (ws) => {
<Server size={32} className="text-green-500" /> <Server size={32} className="text-green-500" />
</div> </div>
<span className="font-bold text-sm text-slate-700">Node.js Proxy</span> <span className="font-bold text-sm text-slate-700">Node.js Proxy</span>
{/* AppData Connection */} {/* AppData Connection */}
<div className="absolute -bottom-16 left-1/2 -translate-x-1/2 flex flex-col items-center"> <div className="absolute -bottom-16 left-1/2 -translate-x-1/2 flex flex-col items-center">
<div className="h-6 w-px border-l border-dashed border-slate-300"></div> <div className="h-6 w-px border-l border-dashed border-slate-300"></div>
<div className="bg-white border border-slate-300 px-2 py-1 rounded text-[10px] flex items-center gap-1 text-yellow-600 shadow-sm"> <div className="bg-white border border-slate-300 px-2 py-1 rounded text-[10px] flex items-center gap-1 text-yellow-600 shadow-sm">
<HardDrive size={10} /> <HardDrive size={10} />
AppData/Config AppData/Config
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col items-center gap-1 flex-1"> <div className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-orange-600 bg-orange-100 px-2 py-0.5 rounded border border-orange-200 font-mono">FTP / SFTP</span> <span className="text-[10px] text-orange-600 bg-orange-100 px-2 py-0.5 rounded border border-orange-200 font-mono">FTP / SFTP</span>
<ArrowLeftRight className="text-slate-400 w-full" /> <ArrowLeftRight className="text-slate-400 w-full" />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -156,35 +154,35 @@ wss.on('connection', (ws) => {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
SFTP와 FTP를 . SFTP와 FTP를 .
</p> </p>
<button <button
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium transition-colors shadow-sm" className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium transition-colors shadow-sm"
> >
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '복사됨' : '코드 복사'} {copied ? '복사됨' : '코드 복사'}
</button> </button>
</div> </div>
<div className="relative group"> <div className="relative group">
<pre className="bg-slate-800 p-4 rounded-lg overflow-x-auto text-xs font-mono text-slate-200 border border-slate-700 leading-relaxed shadow-inner"> <pre className="bg-slate-800 p-4 rounded-lg overflow-x-auto text-xs font-mono text-slate-200 border border-slate-700 leading-relaxed shadow-inner">
{backendCodeDisplay} {backendCodeDisplay}
</pre> </pre>
</div> </div>
<p className="text-xs text-slate-400 italic text-center"> <p className="text-xs text-slate-400 italic text-center">
'백엔드 다운로드' . '백엔드 다운로드' .
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="p-4 border-t border-slate-200 bg-slate-50 rounded-b-lg flex justify-end"> <div className="p-4 border-t border-slate-200 bg-slate-50 rounded-b-lg flex justify-end">
<button onClick={onClose} className="px-4 py-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-600 rounded text-sm transition-colors shadow-sm"> <button onClick={onClose} className="px-4 py-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-600 rounded text-sm transition-colors shadow-sm">
</button> </button>
</div> </div>
</div> </div>
</div> </div>

1
make.bat Normal file
View File

@@ -0,0 +1 @@
pkg backend_proxy.cjs --targets node18-win-x64 --output webftp-backend.exe

2730
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,16 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"proxy": "node backend_proxy.cjs"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.3",
"@google/genai": "^1.37.0", "@google/genai": "^1.37.0",
"basic-ftp": "^5.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react-dom": "^19.2.3" "react": "^19.2.3",
"react-dom": "^19.2.3",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",

View File

@@ -1,68 +0,0 @@
import { GoogleGenAI, Type } from "@google/genai";
import { FileItem, FileType } from "../types";
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY || '' });
export const generateRemoteFileList = async (host: string, path: string): Promise<FileItem[]> => {
try {
const model = 'gemini-2.5-flash-latest';
const prompt = `
You are simulating an FTP server file listing for the host: "${host}" at path: "${path}".
Generate a realistic list of 8-15 files and folders that might exist on this specific type of server.
If the host sounds corporate, use corporate files. If it sounds like a game server, use game files.
If it sounds like NASA, use space data files.
Return a JSON array of objects.
`;
const response = await ai.models.generateContent({
model: model,
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
type: { type: Type.STRING, enum: ["FILE", "FOLDER"] },
size: { type: Type.INTEGER, description: "Size in bytes. Folders should be 0 or 4096." },
},
required: ["name", "type", "size"]
}
}
}
});
const data = JSON.parse(response.text || '[]');
return data.map((item: any, index: number) => ({
id: `gen-${Date.now()}-${index}`,
name: item.name,
type: item.type === 'FOLDER' ? FileType.FOLDER : FileType.FILE,
size: item.size,
date: new Date(Date.now() - Math.floor(Math.random() * 10000000000)).toISOString(),
permissions: item.type === 'FOLDER' ? 'drwxr-xr-x' : '-rw-r--r--'
}));
} catch (error) {
console.error("Gemini generation failed", error);
// Fallback if API fails
return [
{ id: 'err1', name: 'connection_retry.log', type: FileType.FILE, size: 1024, date: new Date().toISOString(), permissions: '-rw-r--r--' },
{ id: 'err2', name: 'backup', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' }
];
}
};
export const generateServerMessage = async (host: string): Promise<string> => {
try {
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `Write a short, single-line FTP welcome message (220 Service ready) for a server named "${host}". Make it sound authentic to the domain info.`,
});
return response.text?.trim() || `220 ${host} FTP Server ready.`;
} catch (e) {
return `220 ${host} FTP Server ready.`;
}
}

View File

@@ -1,32 +0,0 @@
import { FileItem, FileType } from '../types';
export const generateLocalFiles = (path: string): FileItem[] => {
// Static mock data for local machine (Korean localized)
const baseFiles: FileItem[] = [
{ id: '1', name: '내 문서', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: '2', name: '다운로드', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: '3', name: '바탕화면', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: '4', name: '프로젝트_기획안.pdf', type: FileType.FILE, size: 2450000, date: new Date().toISOString(), permissions: '-rw-r--r--' },
{ id: '5', name: '메모.txt', type: FileType.FILE, size: 1024, date: new Date().toISOString(), permissions: '-rw-r--r--' },
{ id: '6', name: '프로필_사진.png', type: FileType.FILE, size: 540000, date: new Date().toISOString(), permissions: '-rw-r--r--' },
{ id: '7', name: '설치파일.exe', type: FileType.FILE, size: 45000000, date: new Date().toISOString(), permissions: '-rwxr-xr-x' },
];
if (path === '/') return baseFiles;
// Return random files for subdirectories to simulate depth
return [
{ id: `sub-${Math.random()}`, name: '아카이브', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: `sub-${Math.random()}`, name: '데이터_백업.json', type: FileType.FILE, size: Math.floor(Math.random() * 10000), date: new Date().toISOString(), permissions: '-rw-r--r--' }
];
};
// We will use Gemini to generate the remote ones dynamically, but here is a fallback
export const generateFallbackRemoteFiles = (): FileItem[] => {
return [
{ id: 'r1', name: 'public_html', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: 'r2', name: 'www', type: FileType.FOLDER, size: 0, date: new Date().toISOString(), permissions: 'drwxr-xr-x' },
{ id: 'r3', name: '.htaccess', type: FileType.FILE, size: 245, date: new Date().toISOString(), permissions: '-rw-r--r--' },
{ id: 'r4', name: 'error_log', type: FileType.FILE, size: 14500, date: new Date().toISOString(), permissions: '-rw-r--r--' },
];
};

BIN
webftp-backend.exe Normal file

Binary file not shown.