initial commit

This commit is contained in:
backuppc
2026-01-19 11:04:36 +09:00
commit 8993779ecb
22 changed files with 2478 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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();
});
});

View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, '.'),
}
}
};
});