initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
857
App.tsx
Normal file
857
App.tsx
Normal file
@@ -0,0 +1,857 @@
|
||||
import React, { useState, useEffect } 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 { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
|
||||
import { generateLocalFiles } from './utils/mockData';
|
||||
import { generateRemoteFileList, generateServerMessage } from './services/gemini';
|
||||
|
||||
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 [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);
|
||||
// For Rename
|
||||
const [renameTargetName, setRenameTargetName] = useState('');
|
||||
|
||||
// Local File System
|
||||
const [local, setLocal] = useState<FileSystemState>({
|
||||
path: '/',
|
||||
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());
|
||||
|
||||
// --- Helpers ---
|
||||
const addLog = (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));
|
||||
};
|
||||
|
||||
// --- Backend Download Logic (Updated for SFTP Support) ---
|
||||
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.js
|
||||
*/
|
||||
|
||||
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: 8080 });
|
||||
console.log("🚀 WebZilla Proxy Server running on ws://localhost:8080");
|
||||
|
||||
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
|
||||
});
|
||||
// 패시브 모드는 basic-ftp에서 기본적으로 자동 처리하지만 명시적 설정이 필요할 수 있음
|
||||
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;
|
||||
// Note: handling single file deletion for simplicity in this snippet
|
||||
// In real app, iterate over items
|
||||
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'));
|
||||
// Simple append/replace logic omitted for brevity
|
||||
sites.push(data.siteInfo);
|
||||
fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2));
|
||||
ws.send(JSON.stringify({ type: 'success', message: 'Site saved locally' }));
|
||||
}
|
||||
|
||||
} 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.js';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
addLog('info', '업데이트된 백엔드 코드(SFTP 지원)가 다운로드되었습니다.');
|
||||
};
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
// Initial Load
|
||||
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 ---
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (connection.connected) {
|
||||
setConnection(prev => ({ ...prev, connected: false }));
|
||||
addLog('info', '서버 연결이 해제되었습니다.');
|
||||
setRemote(prev => ({ ...prev, files: [], path: '/' }));
|
||||
setSelectedRemoteIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
setConnection(prev => ({ ...prev, connecting: true }));
|
||||
addLog('command', `CONNECT [${connection.protocol.toUpperCase()}] ${connection.user}@${connection.host}:${connection.port} ${connection.passive ? '(Passive)' : ''}`);
|
||||
|
||||
// Simulation delays
|
||||
setTimeout(() => addLog('info', `주소 해석 중: ${connection.host}`), 500);
|
||||
setTimeout(() => addLog('success', '연결 성공, 환영 메시지 대기 중...'), 1500);
|
||||
|
||||
// AI generated content
|
||||
setTimeout(async () => {
|
||||
const welcomeMsg = await generateServerMessage(connection.host);
|
||||
addLog('response', welcomeMsg);
|
||||
if (connection.protocol === 'ftp') {
|
||||
addLog('command', 'USER ' + connection.user);
|
||||
addLog('response', '331 Password required');
|
||||
addLog('command', 'PASS ******');
|
||||
addLog('response', '230 Logged on');
|
||||
} 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) => {
|
||||
if (!connection.connected) return;
|
||||
setRemote(prev => ({ ...prev, isLoading: true }));
|
||||
setSelectedRemoteIds(new Set()); // Clear selection
|
||||
addLog('command', `CWD ${path}`);
|
||||
|
||||
// Simulate network latency
|
||||
setTimeout(async () => {
|
||||
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) => {
|
||||
setLocal(prev => ({ ...prev, isLoading: true }));
|
||||
setSelectedLocalIds(new Set()); // Clear selection
|
||||
setTimeout(() => {
|
||||
setLocal({
|
||||
path,
|
||||
files: generateLocalFiles(path),
|
||||
isLoading: false
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// --- 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;
|
||||
|
||||
const newItem: FileItem = {
|
||||
id: `new-${Date.now()}`,
|
||||
name,
|
||||
type: FileType.FOLDER,
|
||||
size: 0,
|
||||
date: new Date().toISOString(),
|
||||
permissions: 'drwxr-xr-x'
|
||||
};
|
||||
|
||||
if (isLocal) {
|
||||
setLocal(prev => ({ ...prev, files: [...prev.files, newItem] }));
|
||||
addLog('info', `[로컬] 디렉토리 생성: ${name}`);
|
||||
} else {
|
||||
setRemote(prev => ({ ...prev, files: [...prev.files, newItem] }));
|
||||
addLog('command', `MKD ${name}`);
|
||||
addLog('success', `257 "${name}" created`);
|
||||
}
|
||||
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) {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
files: prev.files.map(f => f.id === targetFile.id ? { ...f, name: newName } : f)
|
||||
}));
|
||||
addLog('info', `[로컬] 이름 변경: ${targetFile.name} -> ${newName}`);
|
||||
} else {
|
||||
setRemote(prev => ({
|
||||
...prev,
|
||||
files: prev.files.map(f => f.id === targetFile.id ? { ...f, name: newName } : f)
|
||||
}));
|
||||
addLog('command', `RNFR ${targetFile.name}`);
|
||||
addLog('command', `RNTO ${newName}`);
|
||||
addLog('success', '250 Rename successful');
|
||||
}
|
||||
setActiveModal(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
const isLocal = modalTargetIsLocal;
|
||||
const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds;
|
||||
|
||||
if (isLocal) {
|
||||
setLocal(prev => ({ ...prev, files: prev.files.filter(f => !selectedIds.has(f.id)) }));
|
||||
setSelectedLocalIds(new Set());
|
||||
addLog('info', `[로컬] ${selectedIds.size}개 항목 삭제됨`);
|
||||
} else {
|
||||
setRemote(prev => ({ ...prev, files: prev.files.filter(f => !selectedIds.has(f.id)) }));
|
||||
setSelectedRemoteIds(new Set());
|
||||
addLog('command', `DELE [${selectedIds.size} items]`);
|
||||
addLog('success', '250 Deleted successfully');
|
||||
}
|
||||
setActiveModal(null);
|
||||
};
|
||||
|
||||
// --- Transfer Logic ---
|
||||
|
||||
const handleUpload = () => {
|
||||
if (selectedLocalIds.size === 0 || !connection.connected) return;
|
||||
const selectedFiles = getSelectedFiles(local.files, selectedLocalIds);
|
||||
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 = () => {
|
||||
if (selectedRemoteIds.size === 0 || !connection.connected) return;
|
||||
const selectedFiles = getSelectedFiles(remote.files, selectedRemoteIds);
|
||||
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 ---
|
||||
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,
|
||||
connecting: false
|
||||
});
|
||||
setShowSiteManager(false);
|
||||
setTimeout(() => handleConnect(), 100);
|
||||
};
|
||||
|
||||
// --- 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)} />
|
||||
<SiteManagerModal
|
||||
isOpen={showSiteManager}
|
||||
onClose={() => setShowSiteManager(false)}
|
||||
initialSites={savedSites}
|
||||
onSaveSites={(sites) => {
|
||||
setSavedSites(sites);
|
||||
localStorage.setItem('webzilla_sites', JSON.stringify(sites));
|
||||
}}
|
||||
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;
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# 1단계: 빌드 (Node.js)
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 2단계: 실행 (Nginx)
|
||||
FROM nginx:stable-alpine
|
||||
# 빌드된 파일들을 Nginx의 기본 경로로 복사
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# (선택) 커스텀 nginx 설정을 넣고 싶다면 아래 주석 해제
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1kdwJgY17mbT6yrgo6Sf_-gQT9xxf3uSV
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
149
backend_proxy.js
Normal file
149
backend_proxy.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* WebZilla 백엔드 프록시 서버 (Node.js) v1.1
|
||||
*
|
||||
* 기능 추가:
|
||||
* - 로컬 파일 시스템 접근 (fs)
|
||||
* - 설정 데이터 저장 (AppData/Roaming 또는 ~/.config)
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const ftp = require('basic-ftp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// --- 로컬 저장소 경로 설정 (AppData 구현) ---
|
||||
function getConfigDir() {
|
||||
const homedir = os.homedir();
|
||||
|
||||
// Windows: C:\Users\User\AppData\Roaming\WebZilla
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
|
||||
}
|
||||
// macOS: ~/Library/Application Support/WebZilla
|
||||
else if (process.platform === 'darwin') {
|
||||
return path.join(homedir, 'Library', 'Application Support', 'WebZilla');
|
||||
}
|
||||
// Linux: ~/.config/webzilla
|
||||
else {
|
||||
return path.join(homedir, '.config', 'webzilla');
|
||||
}
|
||||
}
|
||||
|
||||
// 앱 시작 시 설정 디렉토리 생성
|
||||
const configDir = getConfigDir();
|
||||
if (!fs.existsSync(configDir)) {
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
console.log(`📂 설정 폴더가 생성되었습니다: ${configDir}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ 설정 폴더 생성 실패: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`📂 설정 폴더 로드됨: ${configDir}`);
|
||||
}
|
||||
|
||||
const wss = new WebSocket.Server({ port: 8080 });
|
||||
|
||||
console.log("🚀 WebZilla FTP Proxy Server가 ws://localhost:8080 에서 실행 중입니다.");
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log("클라이언트가 접속했습니다.");
|
||||
|
||||
const client = new ftp.Client();
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
switch (data.command) {
|
||||
// --- FTP 연결 관련 ---
|
||||
case 'CONNECT':
|
||||
console.log(`FTP 연결 시도: ${data.user}@${data.host}:${data.port}`);
|
||||
try {
|
||||
await client.access({
|
||||
host: data.host,
|
||||
user: data.user,
|
||||
password: data.pass,
|
||||
port: parseInt(data.port),
|
||||
secure: false
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP 서버에 연결되었습니다.' }));
|
||||
console.log("FTP 연결 성공");
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `연결 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'LIST':
|
||||
if (client.closed) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
||||
return;
|
||||
}
|
||||
const listPath = data.path || '/';
|
||||
const list = await client.list(listPath);
|
||||
|
||||
const files = list.map(f => ({
|
||||
id: `ftp-${Date.now()}-${Math.random()}`,
|
||||
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 }));
|
||||
break;
|
||||
|
||||
case 'DISCONNECT':
|
||||
client.close();
|
||||
ws.send(JSON.stringify({ type: 'status', status: 'disconnected', message: '연결이 종료되었습니다.' }));
|
||||
break;
|
||||
|
||||
// --- 로컬 설정 저장 관련 (새로 추가됨) ---
|
||||
case 'SAVE_SITE':
|
||||
// 예: 사이트 정보를 JSON 파일로 저장
|
||||
try {
|
||||
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));
|
||||
|
||||
console.log(`💾 사이트 정보 저장됨: ${data.siteInfo.host}`);
|
||||
ws.send(JSON.stringify({ type: 'success', message: '사이트 정보가 로컬(AppData)에 저장되었습니다.' }));
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `저장 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'GET_SITES':
|
||||
try {
|
||||
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) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `로드 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`알 수 없는 명령: ${data.command}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("오류 발생:", err);
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log("클라이언트 접속 종료");
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
153
components/FileActionModals.tsx
Normal file
153
components/FileActionModals.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FolderPlus, FilePenLine, Trash2, X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
// --- Create Folder Modal ---
|
||||
interface CreateFolderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, onClose, onConfirm }) => {
|
||||
const [folderName, setFolderName] = useState('새 폴더');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFolderName('새 폴더');
|
||||
// Auto focus hack
|
||||
setTimeout(() => document.getElementById('new-folder-input')?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<FolderPlus size={16} className="text-blue-500" /> 디렉토리 생성
|
||||
</h3>
|
||||
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">새 디렉토리 이름:</label>
|
||||
<input
|
||||
id="new-folder-input"
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm(folderName)}
|
||||
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
<button onClick={() => onConfirm(folderName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Rename Modal ---
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean;
|
||||
currentName: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (newName: string) => void;
|
||||
}
|
||||
|
||||
export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, onClose, onConfirm }) => {
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setNewName(currentName);
|
||||
setTimeout(() => document.getElementById('rename-input')?.focus(), 50);
|
||||
}
|
||||
}, [isOpen, currentName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<FilePenLine size={16} className="text-yellow-500" /> 이름 변경/이동
|
||||
</h3>
|
||||
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">현재 이름:</label>
|
||||
<div className="text-sm text-slate-600 bg-slate-50 p-2 rounded border border-slate-200 mb-3 select-all">{currentName}</div>
|
||||
|
||||
<label className="block text-xs text-slate-500 mb-1">새 이름:</label>
|
||||
<input
|
||||
id="rename-input"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm(newName)}
|
||||
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
<button onClick={() => onConfirm(newName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Delete Modal ---
|
||||
interface DeleteModalProps {
|
||||
isOpen: boolean;
|
||||
fileCount: number;
|
||||
fileNames: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fileNames, onClose, onConfirm }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<Trash2 size={16} className="text-red-500" /> 파일 삭제
|
||||
</h3>
|
||||
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="bg-red-50 p-2 rounded h-fit shrink-0">
|
||||
<AlertTriangle size={24} className="text-red-500" />
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 min-w-0">
|
||||
<p className="mb-2">정말로 다음 {fileCount}개 항목을 삭제하시겠습니까?</p>
|
||||
<ul className="list-disc list-inside text-xs text-slate-500 max-h-32 overflow-y-auto bg-slate-50 p-2 rounded border border-slate-200 mb-2">
|
||||
{fileNames.map((name, i) => (
|
||||
<li key={i} className="truncate">{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-red-500 font-semibold">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
<button onClick={onConfirm} className="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-500 text-white rounded shadow-md shadow-red-500/20">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
251
components/FilePane.tsx
Normal file
251
components/FilePane.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { FileItem, FileType } from '../types';
|
||||
import { FileIcon } from './Icon';
|
||||
import { formatBytes, formatDate } from '../utils/formatters';
|
||||
import { ArrowUp, Server, Monitor, FolderOpen, RefreshCw, FolderPlus, Trash2, FilePenLine, Search } from 'lucide-react';
|
||||
|
||||
interface FilePaneProps {
|
||||
title: string;
|
||||
icon: 'local' | 'remote';
|
||||
path: string;
|
||||
files: FileItem[];
|
||||
isLoading: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onSelectionChange: (ids: Set<string>) => void;
|
||||
selectedIds: Set<string>;
|
||||
connected?: boolean;
|
||||
onCreateFolder?: () => void;
|
||||
onDelete?: () => void;
|
||||
onRename?: () => void;
|
||||
}
|
||||
|
||||
const FilePane: React.FC<FilePaneProps> = ({
|
||||
title,
|
||||
icon,
|
||||
path,
|
||||
files,
|
||||
isLoading,
|
||||
onNavigate,
|
||||
onNavigateUp,
|
||||
onSelectionChange,
|
||||
selectedIds,
|
||||
connected = true,
|
||||
onCreateFolder,
|
||||
onDelete,
|
||||
onRename
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedId, setLastClickedId] = useState<string | null>(null);
|
||||
|
||||
// Filter files based on search term
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!searchTerm) return files;
|
||||
return files.filter(f => f.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [files, searchTerm]);
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault(); // Prevent text selection
|
||||
|
||||
let newSelected = new Set(selectedIds);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Toggle selection
|
||||
if (newSelected.has(file.id)) {
|
||||
newSelected.delete(file.id);
|
||||
} else {
|
||||
newSelected.add(file.id);
|
||||
}
|
||||
setLastClickedId(file.id);
|
||||
} else if (e.shiftKey && lastClickedId) {
|
||||
// Range selection
|
||||
const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId);
|
||||
const currentIndex = displayFiles.findIndex(f => f.id === file.id);
|
||||
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
|
||||
newSelected = new Set();
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelected.add(displayFiles[i].id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single selection
|
||||
newSelected = new Set([file.id]);
|
||||
setLastClickedId(file.id);
|
||||
}
|
||||
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="bg-slate-50 p-2 border-b border-slate-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm">
|
||||
{icon === 'local' ? <Monitor size={16} /> : <Server size={16} />}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white px-2 py-1 rounded border border-slate-200 text-xs text-slate-500 flex-1 ml-4 truncate shadow-sm">
|
||||
<span className="text-slate-400">경로:</span>
|
||||
<span className="font-mono text-slate-700 select-all">{path}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center">
|
||||
<button
|
||||
onClick={onNavigateUp}
|
||||
disabled={path === '/'}
|
||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="상위 폴더로 이동"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-slate-200 mx-1"></div>
|
||||
<button
|
||||
onClick={onCreateFolder}
|
||||
disabled={!connected || isLoading}
|
||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="새 폴더 생성"
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRename}
|
||||
disabled={selectedIds.size !== 1 || !connected || isLoading}
|
||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="이름 변경"
|
||||
>
|
||||
<FilePenLine size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={selectedIds.size === 0 || !connected || isLoading}
|
||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors"
|
||||
title="새로고침"
|
||||
onClick={() => onNavigate(path)} // Simple refresh
|
||||
disabled={!connected || isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative group">
|
||||
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Grid */}
|
||||
<div className="flex-1 overflow-auto bg-white relative" onClick={() => onSelectionChange(new Set())}>
|
||||
{!connected ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
|
||||
<Server size={48} className="mb-4 opacity-20" />
|
||||
<p>서버에 연결되지 않음</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
|
||||
<p className="mt-4 text-sm">목록 조회 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs text-left border-collapse" onClick={(e) => e.stopPropagation()}>
|
||||
<thead className="bg-slate-50 text-slate-500 sticky top-0 z-10 shadow-sm">
|
||||
<tr>
|
||||
<th className="p-2 w-8 font-medium border-b border-slate-200"></th>
|
||||
<th className="p-2 font-medium border-b border-slate-200">파일명</th>
|
||||
<th className="p-2 w-24 font-medium border-b border-slate-200 text-right">크기</th>
|
||||
<th className="p-2 w-32 font-medium border-b border-slate-200 text-right hidden lg:table-cell">종류</th>
|
||||
<th className="p-2 w-32 font-medium border-b border-slate-200 text-right hidden md:table-cell">수정일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Back Button Row */}
|
||||
{path !== '/' && !searchTerm && (
|
||||
<tr
|
||||
className="hover:bg-slate-50 cursor-pointer text-slate-500"
|
||||
onClick={onNavigateUp}
|
||||
>
|
||||
<td className="p-2 text-center"><FolderOpen size={14} /></td>
|
||||
<td className="p-2 font-semibold">..</td>
|
||||
<td className="p-2"></td>
|
||||
<td className="p-2 hidden lg:table-cell"></td>
|
||||
<td className="p-2 hidden md:table-cell"></td>
|
||||
</tr>
|
||||
)}
|
||||
{displayFiles.map((file) => {
|
||||
const isSelected = selectedIds.has(file.id);
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={(e) => handleRowClick(e, file)}
|
||||
onDoubleClick={() => {
|
||||
if (file.type === FileType.FOLDER) {
|
||||
onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`);
|
||||
onSelectionChange(new Set()); // Clear selection on navigate
|
||||
setSearchTerm(''); // Clear search on navigate
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer border-b border-slate-50 group select-none ${
|
||||
isSelected
|
||||
? 'bg-blue-100 text-blue-900 border-blue-200'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<td className="p-2 text-center">
|
||||
<FileIcon name={file.name} type={file.type} className="w-4 h-4" />
|
||||
</td>
|
||||
<td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]">
|
||||
{file.name}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-slate-500">
|
||||
{file.type === FileType.FILE ? formatBytes(file.size) : ''}
|
||||
</td>
|
||||
<td className="p-2 text-right text-slate-400 hidden lg:table-cell">
|
||||
{file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'}
|
||||
</td>
|
||||
<td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap">
|
||||
{formatDate(file.date).split(',')[0]}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{displayFiles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-400">
|
||||
{searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Status */}
|
||||
<div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between">
|
||||
<span>{files.length} 개 항목 {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span>
|
||||
<span>{connected ? '준비됨' : '오프라인'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePane;
|
||||
32
components/Icon.tsx
Normal file
32
components/Icon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
File as FileGeneric,
|
||||
Server,
|
||||
Monitor,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
FolderOpen
|
||||
} from 'lucide-react';
|
||||
import { FileType } from '../types';
|
||||
|
||||
export const FileIcon: React.FC<{ name: string; type: FileType; className?: string }> = ({ name, type, className }) => {
|
||||
if (type === FileType.FOLDER) return <Folder className={`text-blue-500 fill-blue-500/20 ${className}`} />;
|
||||
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext || '')) return <FileImage className={`text-purple-600 ${className}`} />;
|
||||
if (['mp4', 'mov', 'avi'].includes(ext || '')) return <FileVideo className={`text-pink-600 ${className}`} />;
|
||||
if (['js', 'ts', 'tsx', 'html', 'css', 'json', 'py', 'php'].includes(ext || '')) return <FileCode className={`text-yellow-600 ${className}`} />;
|
||||
if (['txt', 'md', 'log'].includes(ext || '')) return <FileText className={`text-slate-500 ${className}`} />;
|
||||
|
||||
return <FileGeneric className={`text-slate-400 ${className}`} />;
|
||||
};
|
||||
57
components/LogConsole.tsx
Normal file
57
components/LogConsole.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { LogEntry } from '../types';
|
||||
|
||||
interface LogConsoleProps {
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
const LogConsole: React.FC<LogConsoleProps> = ({ logs }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const getColor = (type: LogEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'command': return 'text-blue-600';
|
||||
case 'response': return 'text-green-600';
|
||||
case 'error': return 'text-red-600';
|
||||
case 'success': return 'text-emerald-600';
|
||||
default: return 'text-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden font-mono text-xs shadow-sm">
|
||||
<div className="bg-slate-50 px-3 py-1 border-b border-slate-200 text-slate-500 text-xs font-semibold uppercase tracking-wider">
|
||||
연결 로그 (Connection Log)
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 space-y-1 bg-white"
|
||||
>
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex gap-3 hover:bg-slate-50 p-0.5 rounded">
|
||||
<span className="text-slate-400 shrink-0 select-none">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={`${getColor(log.type)} break-all`}>
|
||||
<span className="font-bold mr-2 uppercase text-[10px] opacity-70 border border-slate-200 px-1 rounded bg-slate-50 text-slate-600">
|
||||
{log.type}
|
||||
</span>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-slate-400 italic px-2">로그 없음. 연결 대기 중...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogConsole;
|
||||
194
components/SettingsModal.tsx
Normal file
194
components/SettingsModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Copy, Check, Server, Globe, ArrowLeftRight, HardDrive } from 'lucide-react';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'arch' | 'code'>('arch');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const backendCodeDisplay = `/**
|
||||
* WebZilla Backend Proxy (Node.js)
|
||||
* Supports: FTP (basic-ftp) & SFTP (ssh2-sftp-client)
|
||||
* Dependencies: npm install ws basic-ftp ssh2-sftp-client
|
||||
*/
|
||||
const WebSocket = require('ws');
|
||||
const ftp = require('basic-ftp');
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
// ... imports
|
||||
|
||||
const wss = new WebSocket.Server({ port: 8080 });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let ftpClient = new ftp.Client();
|
||||
let sftpClient = new SftpClient();
|
||||
let currentProto = 'ftp';
|
||||
|
||||
ws.on('message', async (msg) => {
|
||||
const data = JSON.parse(msg);
|
||||
|
||||
if (data.command === 'CONNECT') {
|
||||
currentProto = data.protocol; // 'ftp' or 'sftp'
|
||||
if (currentProto === 'sftp') {
|
||||
await sftpClient.connect({
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
username: data.user,
|
||||
password: data.pass
|
||||
});
|
||||
} else {
|
||||
await ftpClient.access({
|
||||
host: data.host,
|
||||
user: data.user,
|
||||
password: data.pass
|
||||
});
|
||||
}
|
||||
ws.send(JSON.stringify({ status: 'connected' }));
|
||||
}
|
||||
|
||||
// ... Handling LIST, MKD, DELE for both protocols
|
||||
});
|
||||
});`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(backendCodeDisplay);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<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]">
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
<Server size={20} className="text-blue-600" />
|
||||
시스템 설정 및 아키텍처
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 px-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('arch')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
시스템 구조
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('code')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
백엔드 코드 (Preview)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto flex-1 text-slate-600">
|
||||
{activeTab === 'arch' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-50 p-6 rounded-lg border border-slate-200 flex flex-col md:flex-row items-center justify-between gap-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center border border-blue-200">
|
||||
<Globe size={32} className="text-blue-500" />
|
||||
</div>
|
||||
<span className="font-bold text-sm text-slate-700">브라우저</span>
|
||||
<span className="text-xs text-slate-500">React Client</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<ArrowLeftRight className="text-slate-400 w-full animate-pulse" />
|
||||
<span className="text-xs text-slate-400">JSON Protocol</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 relative">
|
||||
<div className="w-16 h-16 bg-green-50 rounded-full flex items-center justify-center border border-green-200">
|
||||
<Server size={32} className="text-green-500" />
|
||||
</div>
|
||||
<span className="font-bold text-sm text-slate-700">Node.js Proxy</span>
|
||||
|
||||
{/* AppData Connection */}
|
||||
<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="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} />
|
||||
AppData/Config
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<ArrowLeftRight className="text-slate-400 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-16 bg-orange-50 rounded-full flex items-center justify-center border border-orange-200">
|
||||
<Server size={32} className="text-orange-500" />
|
||||
</div>
|
||||
<span className="font-bold text-sm text-slate-700">Remote Server</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-slate-800">업데이트 내역 (v1.1)</h3>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 space-y-1 ml-2">
|
||||
<li><span className="text-green-600 font-semibold">SFTP 지원:</span> SSH2 프로토콜을 사용한 보안 전송 지원 추가.</li>
|
||||
<li><span className="text-blue-600 font-semibold">패시브 모드:</span> 방화벽 환경을 위한 FTP 패시브 모드 토글 UI 추가.</li>
|
||||
<li><span className="text-yellow-600 font-semibold">파일 작업:</span> 폴더 생성, 이름 변경, 삭제를 위한 전용 모달 인터페이스 구현.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
SFTP와 FTP를 모두 지원하는 프록시 서버 코드 미리보기입니다.
|
||||
</p>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? '복사됨' : '코드 복사'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{backendCodeDisplay}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 italic text-center">
|
||||
전체 코드는 메인 화면의 '백엔드 다운로드' 버튼을 통해 받으실 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
347
components/SiteManagerModal.tsx
Normal file
347
components/SiteManagerModal.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Server, FolderPlus, Trash2, Save, Power, Monitor, Settings2 } from 'lucide-react';
|
||||
import { SiteConfig } from '../types';
|
||||
|
||||
interface SiteManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (site: SiteConfig) => void;
|
||||
onSaveSites: (sites: SiteConfig[]) => void;
|
||||
initialSites: SiteConfig[];
|
||||
}
|
||||
|
||||
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveSites,
|
||||
initialSites
|
||||
}) => {
|
||||
const [sites, setSites] = useState<SiteConfig[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<SiteConfig | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'transfer'>('general');
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSites(initialSites);
|
||||
if (initialSites.length > 0 && !selectedId) {
|
||||
selectSite(initialSites[0]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialSites]);
|
||||
|
||||
const selectSite = (site: SiteConfig) => {
|
||||
setSelectedId(site.id);
|
||||
setFormData({ ...site });
|
||||
setIsDirty(false);
|
||||
setActiveTab('general');
|
||||
};
|
||||
|
||||
const handleNewSite = () => {
|
||||
const newSite: SiteConfig = {
|
||||
id: `site-${Date.now()}`,
|
||||
name: '새 사이트',
|
||||
protocol: 'ftp',
|
||||
host: '',
|
||||
port: '21',
|
||||
user: '',
|
||||
pass: '',
|
||||
passiveMode: true
|
||||
};
|
||||
const newSites = [...sites, newSite];
|
||||
setSites(newSites);
|
||||
selectSite(newSite);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedId) return;
|
||||
if (!window.confirm('선택한 사이트를 삭제하시겠습니까?')) return;
|
||||
|
||||
const newSites = sites.filter(s => s.id !== selectedId);
|
||||
setSites(newSites);
|
||||
onSaveSites(newSites); // Auto save on delete
|
||||
|
||||
if (newSites.length > 0) {
|
||||
selectSite(newSites[0]);
|
||||
} else {
|
||||
setSelectedId(null);
|
||||
setFormData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData) return;
|
||||
|
||||
const newSites = sites.map(s => s.id === formData.id ? formData : s);
|
||||
setSites(newSites);
|
||||
onSaveSites(newSites);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
const handleConnectClick = () => {
|
||||
if (formData) {
|
||||
if (isDirty) {
|
||||
handleSave();
|
||||
}
|
||||
onConnect(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const updateForm = (field: keyof SiteConfig, value: any) => {
|
||||
if (!formData) return;
|
||||
const updated = { ...formData, [field]: value };
|
||||
|
||||
// Auto port update based on protocol
|
||||
if (field === 'protocol') {
|
||||
if (value === 'sftp') updated.port = '22';
|
||||
if (value === 'ftp') updated.port = '21';
|
||||
}
|
||||
|
||||
setFormData(updated);
|
||||
|
||||
if (field === 'name') {
|
||||
setSites(sites.map(s => s.id === updated.id ? updated : s));
|
||||
}
|
||||
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-3xl flex flex-col h-[600px] max-h-[90vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-slate-200 bg-slate-50 rounded-t-lg">
|
||||
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<Server size={18} className="text-blue-600" />
|
||||
사이트 관리자 (Site Manager)
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: Site List */}
|
||||
<div className="w-1/3 border-r border-slate-200 flex flex-col bg-slate-50">
|
||||
<div className="p-2 border-b border-slate-200 flex gap-2">
|
||||
<button
|
||||
onClick={handleNewSite}
|
||||
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 text-xs py-1.5 rounded border border-slate-300 flex items-center justify-center gap-1 transition-colors shadow-sm"
|
||||
>
|
||||
<FolderPlus size={14} /> 새 사이트
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedId}
|
||||
className="bg-white hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 text-xs px-2 py-1.5 rounded border border-slate-300 transition-colors disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{sites.map(site => (
|
||||
<div
|
||||
key={site.id}
|
||||
onClick={() => selectSite(site)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${
|
||||
selectedId === site.id
|
||||
? 'bg-blue-100 text-blue-900 border border-blue-200'
|
||||
: 'text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Server size={14} className={selectedId === site.id ? 'text-blue-600' : 'text-slate-400'} />
|
||||
<span className="truncate">{site.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{sites.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-xs mt-10">
|
||||
등록된 사이트가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Site Details Form */}
|
||||
<div className="w-2/3 flex flex-col bg-white">
|
||||
{formData ? (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 px-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
일반 (General)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transfer')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'transfer' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
전송 설정 (Transfer)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 overflow-y-auto">
|
||||
{activeTab === 'general' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">사이트 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200 my-4" />
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">프로토콜</label>
|
||||
<select
|
||||
value={formData.protocol}
|
||||
onChange={(e) => updateForm('protocol', e.target.value as any)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
>
|
||||
<option value="ftp">FTP - 파일 전송 프로토콜</option>
|
||||
<option value="sftp">SFTP - SSH 파일 전송 프로토콜</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">호스트</label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.host}
|
||||
onChange={(e) => updateForm('host', e.target.value)}
|
||||
placeholder="ftp.example.com"
|
||||
className="flex-1 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">포트:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.port}
|
||||
onChange={(e) => updateForm('port', e.target.value)}
|
||||
className="w-16 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm text-center focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">사용자 (ID)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.user}
|
||||
onChange={(e) => updateForm('user', e.target.value)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.pass || ''}
|
||||
onChange={(e) => updateForm('pass', e.target.value)}
|
||||
placeholder="저장하지 않으려면 비워두세요"
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">전송 모드</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${formData.passiveMode !== false ? 'bg-blue-600 border-blue-500' : 'bg-white border-slate-400'}`}>
|
||||
{formData.passiveMode !== false && <div className="w-2 h-2 bg-white rounded-sm" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.passiveMode !== false}
|
||||
onChange={(e) => updateForm('passiveMode', e.target.checked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors">패시브 모드 (Passive Mode) 사용</div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 ml-6">
|
||||
서버가 방화벽/NAT 뒤에 있는 경우 패시브 모드를 사용하는 것이 좋습니다.
|
||||
(FTP 프로토콜에만 적용됩니다)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">제한 설정</h3>
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500">최대 연결 수:</label>
|
||||
<input type="number" defaultValue={2} disabled className="bg-slate-100 border border-slate-300 rounded px-2 py-1 text-sm text-slate-500" />
|
||||
<span className="text-xs text-slate-400 col-span-2">(데모 제한)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
|
||||
<Server size={48} className="mb-4 opacity-20" />
|
||||
<p>왼쪽 목록에서 사이트를 선택하거나</p>
|
||||
<p>'새 사이트'를 클릭하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-3 border-t border-slate-200 bg-slate-50 flex justify-between items-center rounded-b-lg">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData}
|
||||
className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${
|
||||
isDirty
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20'
|
||||
: 'bg-white border border-slate-300 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<Save size={16} /> 저장
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConnectClick}
|
||||
disabled={!formData}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-2 shadow-md shadow-blue-500/20"
|
||||
>
|
||||
<Power size={16} /> 연결
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteManagerModal;
|
||||
76
components/TransferQueue.tsx
Normal file
76
components/TransferQueue.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { TransferItem } from '../types';
|
||||
import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface TransferQueueProps {
|
||||
queue: TransferItem[];
|
||||
}
|
||||
|
||||
const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="bg-slate-50 px-3 py-2 border-b border-slate-200 flex justify-between items-center">
|
||||
<span className="text-slate-600 text-xs font-semibold uppercase tracking-wider">전송 대기열 (Queue)</span>
|
||||
<div className="text-xs text-slate-500">
|
||||
{queue.filter(i => i.status === 'transferring').length} 개 진행 중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<table className="w-full text-xs text-left border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-500 sticky top-0 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="p-2 w-8"></th>
|
||||
<th className="p-2">파일명</th>
|
||||
<th className="p-2 w-24">구분</th>
|
||||
<th className="p-2 w-48">진행률</th>
|
||||
<th className="p-2 w-24 text-right">속도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{queue.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
|
||||
<td className="p-2 text-center">
|
||||
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
|
||||
{item.status === 'failed' && <XCircle size={14} className="text-red-500" />}
|
||||
{item.status === 'queued' && <Clock size={14} className="text-slate-400" />}
|
||||
{item.status === 'transferring' && (
|
||||
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 truncate max-w-[200px] font-medium">{item.filename}</td>
|
||||
<td className="p-2">
|
||||
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
|
||||
{item.direction === 'upload' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
|
||||
{item.direction === 'upload' ? '업로드' : '다운로드'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
|
||||
<div
|
||||
className={`h-full transition-all duration-200 ${
|
||||
item.status === 'completed' ? 'bg-emerald-500' :
|
||||
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
|
||||
</tr>
|
||||
))}
|
||||
{queue.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-400 italic">
|
||||
전송 대기열이 비어있습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferQueue;
|
||||
42
index.html
Normal file
42
index.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebZilla - Cloud FTP Client</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
/* Custom scrollbar for a more native app feel - Light Theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9; /* slate-100 */
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1; /* slate-300 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8; /* slate-400 */
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.37.0",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 overflow-hidden h-screen w-screen selection:bg-blue-200 selection:text-blue-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
Normal file
15
index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "WebZilla",
|
||||
"description": "A modern, web-based FTP client simulator. It uses Gemini to generate realistic remote server file structures based on the hostname you connect to, demonstrating how complex desktop file transfer interfaces can be implemented in a browser environment.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "webzilla",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"@google/genai": "^1.37.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
68
services/gemini.ts
Normal file
68
services/gemini.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
47
types.ts
Normal file
47
types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
export enum FileType {
|
||||
FILE = 'FILE',
|
||||
FOLDER = 'FOLDER'
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FileType;
|
||||
size: number; // in bytes
|
||||
date: string;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'info' | 'success' | 'error' | 'command' | 'response';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TransferItem {
|
||||
id: string;
|
||||
direction: 'upload' | 'download';
|
||||
filename: string;
|
||||
progress: number; // 0 to 100
|
||||
status: 'queued' | 'transferring' | 'completed' | 'failed';
|
||||
speed: string;
|
||||
}
|
||||
|
||||
export interface FileSystemState {
|
||||
path: string;
|
||||
files: FileItem[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: 'ftp' | 'sftp';
|
||||
host: string;
|
||||
port: string;
|
||||
user: string;
|
||||
pass?: string; // Optional for security
|
||||
passiveMode?: boolean;
|
||||
}
|
||||
18
utils/formatters.ts
Normal file
18
utils/formatters.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
32
utils/mockData.ts
Normal file
32
utils/mockData.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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--' },
|
||||
];
|
||||
};
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user