From 8993779ecb323e4ca4daa7f1df6ff74793f1af05 Mon Sep 17 00:00:00 2001 From: backuppc Date: Mon, 19 Jan 2026 11:04:36 +0900 Subject: [PATCH] initial commit --- .gitignore | 24 + App.tsx | 857 ++++++++++++++++++++++++++++++++ Dockerfile | 16 + README.md | 20 + backend_proxy.js | 149 ++++++ components/FileActionModals.tsx | 153 ++++++ components/FilePane.tsx | 251 ++++++++++ components/Icon.tsx | 32 ++ components/LogConsole.tsx | 57 +++ components/SettingsModal.tsx | 194 ++++++++ components/SiteManagerModal.tsx | 347 +++++++++++++ components/TransferQueue.tsx | 76 +++ index.html | 42 ++ index.tsx | 15 + metadata.json | 5 + package.json | 23 + services/gemini.ts | 68 +++ tsconfig.json | 29 ++ types.ts | 47 ++ utils/formatters.ts | 18 + utils/mockData.ts | 32 ++ vite.config.ts | 23 + 22 files changed, 2478 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend_proxy.js create mode 100644 components/FileActionModals.tsx create mode 100644 components/FilePane.tsx create mode 100644 components/Icon.tsx create mode 100644 components/LogConsole.tsx create mode 100644 components/SettingsModal.tsx create mode 100644 components/SiteManagerModal.tsx create mode 100644 components/TransferQueue.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/gemini.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils/formatters.ts create mode 100644 utils/mockData.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..9d277b0 --- /dev/null +++ b/App.tsx @@ -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([]); + const [queue, setQueue] = useState([]); + const [showSettings, setShowSettings] = useState(false); + const [showSiteManager, setShowSiteManager] = useState(false); + const [savedSites, setSavedSites] = useState([]); + + // Modals State + const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null); + const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true); + // For Rename + const [renameTargetName, setRenameTargetName] = useState(''); + + // Local File System + const [local, setLocal] = useState({ + path: '/', + files: [], + isLoading: false + }); + const [selectedLocalIds, setSelectedLocalIds] = useState>(new Set()); + + // Remote File System + const [remote, setRemote] = useState({ + path: '/', + files: [], + isLoading: false + }); + const [selectedRemoteIds, setSelectedRemoteIds] = useState>(new Set()); + + // --- 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) => { + 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 ( +
+ {/* Modals */} + setShowSettings(false)} /> + setShowSiteManager(false)} + initialSites={savedSites} + onSaveSites={(sites) => { + setSavedSites(sites); + localStorage.setItem('webzilla_sites', JSON.stringify(sites)); + }} + onConnect={handleSiteConnect} + /> + + setActiveModal(null)} + onConfirm={handleCreateFolderConfirm} + /> + setActiveModal(null)} + onConfirm={handleRenameConfirm} + /> + setActiveModal(null)} + onConfirm={handleDeleteConfirm} + /> + + {/* 1. Header & Quick Connect */} +
+
+
+
WZ
+ 웹질라 +
+ +
+ + {/* Site Manager Trigger */} + + +
+ + {/* Quick Connect Inputs */} +
+ + setConnection(c => ({...c, host: e.target.value}))} + className="w-48 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none placeholder:text-slate-400 shadow-sm" + placeholder="ftp.example.com" + /> +
+ +
+ + setConnection(c => ({...c, user: e.target.value}))} + className="w-32 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none shadow-sm" + /> +
+ +
+ + setConnection(c => ({...c, pass: e.target.value}))} + className="w-32 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm focus:border-blue-500 focus:outline-none shadow-sm" + /> +
+ +
+ +
+ setConnection(c => ({...c, port: e.target.value}))} + className="w-16 h-[30px] bg-white border border-slate-300 rounded px-2 text-sm text-center focus:border-blue-500 focus:outline-none shadow-sm" + /> + + {/* Passive Mode Toggle Switch */} +
+ +
+
+
+ +
+ + {/* Connect & Options */} +
+ + +
+ + {!connection.connected && ( + + + + + )} +
+ + +
+
+
+
+ + {/* 2. Log View & Sponsor Area */} +
+
+ +
+
+
+ SPONSORED +
+
+
+ GOOGLE ADSENSE +
+
Premium Ad Space Available
+
+ {/* Pattern background for placeholder effect */} +
+
+
+ + {/* 3. Main Split View */} +
+ + {/* Local Pane */} +
+ handleLocalNavigate(local.path.split('/').slice(0, -1).join('/') || '/')} + onSelectionChange={setSelectedLocalIds} + selectedIds={selectedLocalIds} + connected={true} + onCreateFolder={() => initiateCreateFolder(true)} + onDelete={() => initiateDelete(true)} + onRename={() => initiateRename(true)} + /> +
+ + {/* Middle Actions */} +
+ + +
+ + {/* Remote Pane */} +
+ handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')} + onSelectionChange={setSelectedRemoteIds} + selectedIds={selectedRemoteIds} + connected={connection.connected} + onCreateFolder={() => initiateCreateFolder(false)} + onDelete={() => initiateDelete(false)} + onRename={() => initiateRename(false)} + /> +
+
+ + {/* 4. Queue / Status */} +
+ +
+ + {/* Footer */} +
+ WebZilla v1.1.0 - React/TypeScript Demo +
+ Server: {connection.connected ? 'Connected' : 'Disconnected'} + Protocol: {connection.protocol.toUpperCase()} {connection.passive ? '(Passive)' : ''} +
+
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50c8d72 --- /dev/null +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dad38a0 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/backend_proxy.js b/backend_proxy.js new file mode 100644 index 0000000..1bbe7a8 --- /dev/null +++ b/backend_proxy.js @@ -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(); + }); +}); \ No newline at end of file diff --git a/components/FileActionModals.tsx b/components/FileActionModals.tsx new file mode 100644 index 0000000..310dfc1 --- /dev/null +++ b/components/FileActionModals.tsx @@ -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 = ({ 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 ( +
+
+
+

+ 디렉토리 생성 +

+ +
+
+
+ + 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" + /> +
+
+ + +
+
+
+
+ ); +}; + +// --- Rename Modal --- +interface RenameModalProps { + isOpen: boolean; + currentName: string; + onClose: () => void; + onConfirm: (newName: string) => void; +} + +export const RenameModal: React.FC = ({ 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 ( +
+
+
+

+ 이름 변경/이동 +

+ +
+
+
+ +
{currentName}
+ + + 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" + /> +
+
+ + +
+
+
+
+ ); +}; + +// --- Delete Modal --- +interface DeleteModalProps { + isOpen: boolean; + fileCount: number; + fileNames: string[]; + onClose: () => void; + onConfirm: () => void; +} + +export const DeleteModal: React.FC = ({ isOpen, fileCount, fileNames, onClose, onConfirm }) => { + if (!isOpen) return null; + + return ( +
+
+
+

+ 파일 삭제 +

+ +
+
+
+
+ +
+
+

정말로 다음 {fileCount}개 항목을 삭제하시겠습니까?

+
    + {fileNames.map((name, i) => ( +
  • {name}
  • + ))} +
+

이 작업은 되돌릴 수 없습니다.

+
+
+
+ + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/components/FilePane.tsx b/components/FilePane.tsx new file mode 100644 index 0000000..a5aa6a4 --- /dev/null +++ b/components/FilePane.tsx @@ -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) => void; + selectedIds: Set; + connected?: boolean; + onCreateFolder?: () => void; + onDelete?: () => void; + onRename?: () => void; +} + +const FilePane: React.FC = ({ + title, + icon, + path, + files, + isLoading, + onNavigate, + onNavigateUp, + onSelectionChange, + selectedIds, + connected = true, + onCreateFolder, + onDelete, + onRename +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [lastClickedId, setLastClickedId] = useState(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 ( +
+ {/* Header */} +
+
+ {icon === 'local' ? : } + {title} +
+
+ 경로: + {path} +
+
+ + {/* Toolbar */} +
+ +
+ + + + + +
+ + {/* Search Input */} +
+ + 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" + /> +
+
+ + {/* File Grid */} +
onSelectionChange(new Set())}> + {!connected ? ( +
+ +

서버에 연결되지 않음

+
+ ) : isLoading ? ( +
+

목록 조회 중...

+
+ ) : ( + e.stopPropagation()}> + + + + + + + + + + + {/* Back Button Row */} + {path !== '/' && !searchTerm && ( + + + + + + + + )} + {displayFiles.map((file) => { + const isSelected = selectedIds.has(file.id); + return ( + 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' + }`} + > + + + + + + + ); + })} + {displayFiles.length === 0 && ( + + + + )} + +
파일명크기종류수정일
..
+ + + {file.name} + + {file.type === FileType.FILE ? formatBytes(file.size) : ''} + + {file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'} + + {formatDate(file.date).split(',')[0]} +
+ {searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'} +
+ )} +
+ + {/* Footer Status */} +
+ {files.length} 개 항목 {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`} + {connected ? '준비됨' : '오프라인'} +
+
+ ); +}; + +export default FilePane; \ No newline at end of file diff --git a/components/Icon.tsx b/components/Icon.tsx new file mode 100644 index 0000000..5c27ed1 --- /dev/null +++ b/components/Icon.tsx @@ -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 ; + + const ext = name.split('.').pop()?.toLowerCase(); + + if (['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext || '')) return ; + if (['mp4', 'mov', 'avi'].includes(ext || '')) return ; + if (['js', 'ts', 'tsx', 'html', 'css', 'json', 'py', 'php'].includes(ext || '')) return ; + if (['txt', 'md', 'log'].includes(ext || '')) return ; + + return ; +}; \ No newline at end of file diff --git a/components/LogConsole.tsx b/components/LogConsole.tsx new file mode 100644 index 0000000..d60601c --- /dev/null +++ b/components/LogConsole.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useRef } from 'react'; +import { LogEntry } from '../types'; + +interface LogConsoleProps { + logs: LogEntry[]; +} + +const LogConsole: React.FC = ({ logs }) => { + const scrollRef = useRef(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 ( +
+
+ 연결 로그 (Connection Log) +
+
+ {logs.map((log) => ( +
+ + {new Date(log.timestamp).toLocaleTimeString()} + + + + {log.type} + + {log.message} + +
+ ))} + {logs.length === 0 && ( +
로그 없음. 연결 대기 중...
+ )} +
+
+ ); +}; + +export default LogConsole; \ No newline at end of file diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx new file mode 100644 index 0000000..af5285f --- /dev/null +++ b/components/SettingsModal.tsx @@ -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 = ({ 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 ( +
+
+ + {/* Header */} +
+

+ + 시스템 설정 및 아키텍처 +

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'arch' ? ( +
+
+
+
+ +
+ 브라우저 + React Client +
+ +
+ WebSocket + + JSON Protocol +
+ +
+
+ +
+ Node.js Proxy + + {/* AppData Connection */} +
+
+
+ + AppData/Config +
+
+
+ +
+ FTP / SFTP + +
+ +
+
+ +
+ Remote Server +
+
+ +
+

업데이트 내역 (v1.1)

+
    +
  • SFTP 지원: SSH2 프로토콜을 사용한 보안 전송 지원 추가.
  • +
  • 패시브 모드: 방화벽 환경을 위한 FTP 패시브 모드 토글 UI 추가.
  • +
  • 파일 작업: 폴더 생성, 이름 변경, 삭제를 위한 전용 모달 인터페이스 구현.
  • +
+
+
+ ) : ( +
+
+

+ SFTP와 FTP를 모두 지원하는 프록시 서버 코드 미리보기입니다. +

+ +
+ +
+
+                  {backendCodeDisplay}
+                
+
+ +

+ 전체 코드는 메인 화면의 '백엔드 다운로드' 버튼을 통해 받으실 수 있습니다. +

+
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default SettingsModal; \ No newline at end of file diff --git a/components/SiteManagerModal.tsx b/components/SiteManagerModal.tsx new file mode 100644 index 0000000..1b61d2d --- /dev/null +++ b/components/SiteManagerModal.tsx @@ -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 = ({ + isOpen, + onClose, + onConnect, + onSaveSites, + initialSites +}) => { + const [sites, setSites] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [formData, setFormData] = useState(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 ( +
+
+ + {/* Header */} +
+

+ + 사이트 관리자 (Site Manager) +

+ +
+ +
+ {/* Left: Site List */} +
+
+ + +
+ +
+ {sites.map(site => ( +
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' + }`} + > + + {site.name} +
+ ))} + {sites.length === 0 && ( +
+ 등록된 사이트가 없습니다. +
+ )} +
+
+ + {/* Right: Site Details Form */} +
+ {formData ? ( + <> + {/* Tabs */} +
+ + +
+ +
+ {activeTab === 'general' ? ( +
+
+ + 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" + /> +
+ +
+ +
+ + +
+ +
+ +
+ 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" + /> +
+ 포트: + 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" + /> +
+
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ ) : ( +
+
+

전송 모드

+
+
+ +
+

제한 설정

+
+ + + (데모 제한) +
+
+
+ )} +
+ + ) : ( +
+ +

왼쪽 목록에서 사이트를 선택하거나

+

'새 사이트'를 클릭하세요.

+
+ )} +
+
+ + {/* Footer Actions */} +
+ + +
+ + +
+
+
+
+ ); +}; + +export default SiteManagerModal; \ No newline at end of file diff --git a/components/TransferQueue.tsx b/components/TransferQueue.tsx new file mode 100644 index 0000000..322d764 --- /dev/null +++ b/components/TransferQueue.tsx @@ -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 = ({ queue }) => { + return ( +
+
+ 전송 대기열 (Queue) +
+ {queue.filter(i => i.status === 'transferring').length} 개 진행 중 +
+
+ +
+ + + + + + + + + + + + {queue.map((item) => ( + + + + + + + + ))} + {queue.length === 0 && ( + + + + )} + +
파일명구분진행률속도
+ {item.status === 'completed' && } + {item.status === 'failed' && } + {item.status === 'queued' && } + {item.status === 'transferring' && ( +
+ )} +
{item.filename} + + {item.direction === 'upload' ? : } + {item.direction === 'upload' ? '업로드' : '다운로드'} + + +
+
+
+
{item.speed}
+ 전송 대기열이 비어있습니다. +
+
+
+ ); +}; + +export default TransferQueue; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..91e4923 --- /dev/null +++ b/index.html @@ -0,0 +1,42 @@ + + + + + + WebZilla - Cloud FTP Client + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..107789c --- /dev/null +++ b/metadata.json @@ -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": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..00e5cc0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/gemini.ts b/services/gemini.ts new file mode 100644 index 0000000..44e7a8d --- /dev/null +++ b/services/gemini.ts @@ -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 => { + 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 => { + 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.`; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..65aeda0 --- /dev/null +++ b/types.ts @@ -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; +} diff --git a/utils/formatters.ts b/utils/formatters.ts new file mode 100644 index 0000000..adfa0b1 --- /dev/null +++ b/utils/formatters.ts @@ -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' + }); +}; \ No newline at end of file diff --git a/utils/mockData.ts b/utils/mockData.ts new file mode 100644 index 0000000..b513eb9 --- /dev/null +++ b/utils/mockData.ts @@ -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--' }, + ]; +}; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});