Compare commits

..

20 Commits

Author SHA1 Message Date
backuppc
b31b3c6d31 Enhance download center with file size preview and dual options 2026-01-21 15:11:57 +09:00
backuppc
fc9500f99b 로컬실행모드추가 및 로컬실행시에는 백엔드 버튼 숨김 2026-01-21 14:43:10 +09:00
backuppc
c84fd2a7e9 광고추가 2026-01-21 14:25:55 +09:00
backuppc
a1a1971a1f Implement Transfer Queue Enhancements, Smart Queue Clearing, and File Conflict Resolution 2026-01-20 17:29:57 +09:00
backuppc
b3a6d74f1e localRefremoteRef를 도입하여 WebSocket 명령이 항상 최신 localremote 상태를 사용하도록 합니다. 2026-01-20 15:21:23 +09:00
backuppc
467d0f5917 백엔드 버젼표시 2026-01-20 15:13:21 +09:00
ea070816e3 Delete public/webftp-backend.exe 2026-01-20 06:12:36 +00:00
backuppc
86729014d3 .. 2026-01-20 15:08:33 +09:00
backuppc
e28de5f2c3 백엔드파일 추적 제외 2026-01-20 15:08:24 +09:00
backuppc
32fee75e69 make backend file 2026-01-20 15:07:36 +09:00
backuppc
237ed6ea8b add upload/download function 2026-01-20 14:42:44 +09:00
4376babbcc .. 2026-01-19 19:14:18 +09:00
backuppc
37bb2890ce fix: use anchor tag for backend download to show link on hover 2026-01-19 14:28:38 +09:00
backuppc
cbeb55daf1 사이트언어를 한국어로 함 2026-01-19 14:20:29 +09:00
backuppc
3acb952174 fix: correct backend download link and clean up legacy code 2026-01-19 14:17:41 +09:00
backuppc
c86fd099ee 다운로드방식변경 2026-01-19 14:13:58 +09:00
backuppc
b01822c998 연결정보 오류팝업제거 2026-01-19 14:06:26 +09:00
backuppc
79d161d7f7 다운로드 파일명 변경 2026-01-19 14:04:01 +09:00
backuppc
253f6c4fd5 feat: Enhance UX with editable paths, settings refinement, and disconnected state visuals 2026-01-19 14:03:21 +09:00
backuppc
5fd84a7ff1 feat: Add Help System, Local File Operations, Site Manager improvements, and UI refinements 2026-01-19 13:34:14 +09:00
18 changed files with 3699 additions and 1161 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
public/webftp-backend.exe
public/webftp.exe

1197
App.tsx

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,501 @@
/** /**
* WebZilla 백엔드 프록시 서버 (Node.js) v1.1 * WebZilla 백엔드 프록시 서버 (Node.js) v1.2
* *
* 기능 추가: * 기능:
* - 로컬 파일 시스템 접근 (fs) * - WebSocket Proxy (Port: 8090)
* - 설정 데이터 저장 (AppData/Roaming 또는 ~/.config) * - FTP/SFTP 지원
* - 로컬 파일 시스템 탐색 (LOCAL_LIST)
* - 설정 저장 (AppData)
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
*/ */
const APP_VERSION = "0.0.1";
const WebSocket = require('ws'); const WebSocket = require('ws');
const ftp = require('basic-ftp'); const ftp = require('basic-ftp');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { exec } = require('child_process');
const readline = require('readline');
// --- 로컬 저장소 경로 설정 (AppData 구현) --- // --- 로컬 저장소 경로 설정 (AppData 구현) ---
function getConfigDir() { function getConfigDir() {
const homedir = os.homedir(); const homedir = os.homedir();
// Windows: C:\Users\User\AppData\Roaming\WebZilla
if (process.platform === 'win32') { if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla'); return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
} } else if (process.platform === 'darwin') {
// macOS: ~/Library/Application Support/WebZilla
else if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', 'WebZilla'); return path.join(homedir, 'Library', 'Application Support', 'WebZilla');
} } else {
// Linux: ~/.config/webzilla
else {
return path.join(homedir, '.config', 'webzilla'); return path.join(homedir, '.config', 'webzilla');
} }
} }
// 앱 시작 시 설정 디렉토리 생성
const configDir = getConfigDir(); const configDir = getConfigDir();
if (!fs.existsSync(configDir)) { if (!fs.existsSync(configDir)) {
try { try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) { }
fs.mkdirSync(configDir, { recursive: true });
console.log(`📂 설정 폴더가 생성되었습니다: ${configDir}`);
} catch (e) {
console.error(`❌ 설정 폴더 생성 실패: ${e.message}`);
}
} else {
console.log(`📂 설정 폴더 로드됨: ${configDir}`);
} }
const wss = new WebSocket.Server({ port: 8090 }); const PORT = 8090;
console.log("🚀 WebZilla FTP Proxy Server가 ws://localhost:8090 에서 실행 중입니다."); // --- 서버 시작 함수 (재시도 로직 포함) ---
function startServer() {
const http = require('http'); // HTTP server module required
wss.on('connection', (ws) => { const server = http.createServer((req, res) => {
console.log("클라이언트가 접속했습니다."); // Parse URL to handle query strings and decoding
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = decodeURIComponent(parsedUrl.pathname);
const client = new ftp.Client(); // Special handling for executables (Download Center)
if (pathname === '/webftp.exe' || pathname === '/webftp-backend.exe') {
const exeDir = path.dirname(process.execPath);
// Fallback for dev mode -> serve from public
const targetPath = process.pkg ? path.join(exeDir, pathname) : path.join(__dirname, 'public', pathname);
ws.on('message', async (message) => { if (fs.existsSync(targetPath)) {
try { const stat = fs.statSync(targetPath);
const data = JSON.parse(message); res.writeHead(200, {
'Content-Type': 'application/octet-stream',
switch (data.command) { 'Content-Length': stat.size,
// --- FTP 연결 관련 --- 'Content-Disposition': `attachment; filename="${path.basename(targetPath)}"`
case 'CONNECT': });
console.log(`FTP 연결 시도: ${data.user}@${data.host}:${data.port}`); const readStream = fs.createReadStream(targetPath);
try { readStream.pipe(res);
await client.access({ return;
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 })); // Static File Serving
// Handle /ftp base path (strip it)
let normalizedPath = pathname;
if (normalizedPath.startsWith('/ftp')) {
normalizedPath = normalizedPath.replace(/^\/ftp/, '') || '/';
}
let filePath = path.join(__dirname, 'dist', normalizedPath === '/' ? 'index.html' : normalizedPath);
// Prevent traversal
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
res.writeHead(403); res.end('Forbidden'); return;
}
const extname = path.extname(filePath);
// Basic MIME types
const MIME_TYPES = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon'
};
let contentType = MIME_TYPES[extname] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
// SPA fallback
if (!extname || extname === '.html') {
fs.readFile(path.join(__dirname, 'dist', 'index.html'), (err2, content2) => {
if (err2) {
// If index.html is missing (Backend Only Mode), return 404
console.log(`[404] Backend Only Mode - Missing: ${pathname}`);
res.writeHead(404); res.end('Backend Only Mode - No Frontend Assets Found');
} else {
let html = content2.toString('utf-8');
if (process.pkg) {
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}
});
} else {
console.log(`[404] File Not Found: ${pathname}`);
res.writeHead(404); res.end('File Not Found');
}
} else {
console.error(`[500] Server Error for ${pathname}:`, err.code);
res.writeHead(500); res.end(`Server Error: ${err.code}`);
}
} else {
// Determine if we need to inject script for main index.html access
if (filePath.endsWith('index.html') || extname === '.html') {
let html = content.toString('utf-8');
if (process.pkg) {
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
}
});
});
const wss = new WebSocket.Server({ server });
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
handlePortConflict();
} else {
console.error("❌ 서버 오류:", err);
process.exit(1);
} }
}); });
ws.on('close', () => { server.listen(PORT, () => {
console.log("클라이언트 접속 종료"); console.log(`\n🚀 WebZilla Server [v${APP_VERSION}] running at http://localhost:${PORT}`);
client.close(); console.log(`📂 Config: ${configDir}`);
// Check if Frontend Assets exist (dist/index.html)
const frontendExists = fs.existsSync(path.join(__dirname, 'dist', 'index.html'));
if (frontendExists) {
console.log("✨ Frontend detected. Launching browser...");
openBrowser(`http://localhost:${PORT}/ftp/`);
} else {
console.log("🔧 Backend Only Mode. Waiting for connections...");
}
}); });
});
// WSS Connection Handling (Existing Logic)
wss.on('connection', (ws) => {
const client = new ftp.Client();
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
switch (data.command) {
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;
}
try {
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 }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'MKD':
if (client.closed) return;
try {
await client.ensureDir(data.path);
ws.send(JSON.stringify({ type: 'success', message: '폴더 생성 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'DELE':
if (client.closed) return;
try {
if (data.isFolder) await client.removeDir(data.path);
else await client.remove(data.path);
ws.send(JSON.stringify({ type: 'success', message: '삭제 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'RENAME':
if (client.closed) return;
try {
await client.rename(data.from, data.to);
ws.send(JSON.stringify({ type: 'success', message: '이름 변경 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'DISCONNECT':
client.close();
ws.send(JSON.stringify({ type: 'status', status: 'disconnected', message: '연결이 종료되었습니다.' }));
break;
case 'SAVE_SITE':
// Deprecated in favor of SAVE_SITES for full sync, but kept for compatibility
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));
ws.send(JSON.stringify({ type: 'success', message: '사이트가 추가되었습니다.' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `저장 실패: ${err.message}` }));
}
break;
case 'SAVE_SITES':
try {
const sitesFile = path.join(configDir, 'sites.json');
fs.writeFileSync(sitesFile, JSON.stringify(data.sites, null, 2));
ws.send(JSON.stringify({ type: 'success', message: '사이트 목록이 저장되었습니다.' }));
} 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;
case 'LOCAL_LIST':
try {
const targetPath = data.path || os.homedir();
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
const files = entries.map(dirent => {
let size = 0;
let date = new Date().toISOString();
try {
const stats = fs.statSync(path.join(targetPath, dirent.name));
size = stats.size;
date = stats.mtime.toISOString();
} catch (e) { }
return {
id: `local-${Math.random()}`,
name: dirent.name,
type: dirent.isDirectory() ? 'FOLDER' : 'FILE',
size: size,
date: date,
permissions: '-'
};
});
ws.send(JSON.stringify({ type: 'local_list', files, path: targetPath }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `로컬 목록 실패: ${err.message}` }));
}
break;
case 'LOCAL_MKD':
try {
if (!fs.existsSync(data.path)) {
fs.mkdirSync(data.path, { recursive: true });
ws.send(JSON.stringify({ type: 'success', message: '로컬 폴더 생성 완료' }));
} else {
ws.send(JSON.stringify({ type: 'error', message: '이미 존재하는 폴더입니다.' }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `로컬 폴더 생성 실패: ${err.message}` }));
}
break;
case 'LOCAL_RENAME':
try {
fs.renameSync(data.from, data.to);
ws.send(JSON.stringify({ type: 'success', message: '이름 변경 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `이름 변경 실패: ${err.message}` }));
}
break;
case 'LOCAL_DELE':
try {
fs.rmSync(data.path, { recursive: true, force: true });
ws.send(JSON.stringify({ type: 'success', message: '삭제 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `삭제 실패: ${err.message}` }));
}
break;
case 'DOWNLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
type: 'transfer_progress',
id: transferId,
bytes: info.bytes,
bytesOverall: info.bytesOverall,
name: info.name
}));
});
await client.downloadTo(localPath, remotePath);
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '다운로드 완료',
path: localPath
}));
} catch (err) {
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `다운로드 실패: ${err.message}`
}));
}
break;
case 'UPLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
type: 'transfer_progress',
id: transferId,
bytes: info.bytes,
bytesOverall: info.bytesOverall,
name: info.name
}));
});
await client.uploadFrom(localPath, remotePath);
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '업로드 완료',
path: remotePath
}));
} catch (err) {
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `업로드 실패: ${err.message}`
}));
}
break;
}
} catch (err) {
console.error("오류 발생:", err);
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
});
ws.on('close', () => {
client.close();
});
});
}
// --- 포트 충돌 처리 ---
function handlePortConflict() {
// Windows: netstat -ano | findstr :8090
// Mac/Linux: lsof -i :8090
if (process.platform === 'win32') {
exec(`netstat -ano | findstr :${PORT}`, (err, stdout, stderr) => {
if (err || !stdout) {
console.log("실행 중인 프로세스를 찾지 못했습니다. 수동으로 확인해주세요.");
process.exit(1);
return;
}
// Parse PID (Last token in line)
const lines = stdout.trim().split('\n');
const line = lines[0].trim();
const parts = line.split(/\s+/);
const pid = parts[parts.length - 1];
askToKill(pid);
});
} else {
// Simple fallback/notification for non-Windows (or implement lsof)
console.log(`Port ${PORT} is in use. Please kill the process manually.`);
process.exit(1);
}
}
function askToKill(pid) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log(`⚠️ PID [${pid}] 프로세스가 포트 ${PORT}를 사용 중입니다.`);
rl.question(`❓ 해당 프로세스를 종료하고 서버를 시작하시겠습니까? (Y/n): `, (answer) => {
const ans = answer.trim().toLowerCase();
if (ans === '' || ans === 'y' || ans === 'yes') {
console.log(`🔫 PID ${pid} 종료 시도 중...`);
exec(`taskkill /F /PID ${pid}`, (killErr) => {
if (killErr) {
console.error(`❌ 종료 실패: ${killErr.message}`);
process.exit(1);
} else {
console.log("✅ 프로세스가 종료되었습니다. 서버를 다시 시작합니다...");
rl.close();
setTimeout(startServer, 1000); // 1초 후 재시작
}
});
} else {
console.log("🚫 작업을 취소했습니다.");
process.exit(0);
}
});
}
// --- 브라우저 열기 ---
function openBrowser(url) {
const start = (process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open');
exec(`${start} ${url}`, (err) => {
if (err) {
console.log("브라우저를 자동으로 열 수 없습니다:", err.message);
}
});
}
// 초기 실행
startServer();

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { AlertTriangle, X, Check, ArrowRight } from 'lucide-react';
import { formatBytes } from '../utils/formatters';
interface ConflictModalProps {
isOpen: boolean;
sourceFile: { name: string; size: number; date: string };
targetFile: { name: string; size: number; date: string };
onOverwrite: (applyToAll: boolean) => void;
onSkip: (applyToAll: boolean) => void;
onClose: () => void; // Usually acts as cancel/skip logic if forced closed
}
const ConflictModal: React.FC<ConflictModalProps> = ({
isOpen,
sourceFile,
targetFile,
onOverwrite,
onSkip,
onClose
}) => {
const [applyToAll, setApplyToAll] = useState(false);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-[500px] max-w-full m-4 flex flex-col overflow-hidden">
{/* Header */}
<div className="bg-yellow-50 px-4 py-3 border-b border-yellow-100 flex justify-between items-center">
<div className="flex items-center gap-2 text-yellow-700">
<AlertTriangle size={20} />
<h3 className="font-bold text-sm"> (File Conflict)</h3>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6 flex flex-col gap-4">
<p className="text-slate-700 text-sm">
. ?
</p>
<div className="bg-slate-50 border border-slate-200 rounded p-4 text-xs flex flex-col gap-3">
{/* Target (Existing) */}
<div className="flex justify-between items-center">
<div className="flex flex-col gap-1 w-1/2 pr-2 border-r border-slate-200">
<span className="font-semibold text-slate-500"> (Target)</span>
<span className="font-medium text-slate-800 truncate" title={targetFile.name}>{targetFile.name}</span>
<div className="flex gap-2 text-slate-500">
<span>{formatBytes(targetFile.size)}</span>
<span>{new Date(targetFile.date).toLocaleString()}</span>
</div>
</div>
{/* Source (New) */}
<div className="flex flex-col gap-1 w-1/2 pl-2 text-right">
<span className="font-semibold text-blue-600"> (Source)</span>
<span className="font-medium text-slate-800 truncate" title={sourceFile.name}>{sourceFile.name}</span>
<div className="flex gap-2 justify-end text-slate-500">
<span>{formatBytes(sourceFile.size)}</span>
<span>{new Date(sourceFile.date).toLocaleString()}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="applyToAll"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="applyToAll" className="text-sm text-slate-600 cursor-pointer select-none">
</label>
</div>
</div>
{/* Footer */}
<div className="bg-slate-50 px-4 py-3 border-t border-slate-200 flex justify-end gap-2">
<button
onClick={() => onSkip(applyToAll)}
className="px-4 py-2 bg-white border border-slate-300 rounded text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
(Skip)
</button>
<button
onClick={() => onOverwrite(applyToAll)}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm"
>
(Overwrite)
</button>
</div>
</div>
</div>
);
};
export default ConflictModal;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react';
import { Download, Package, Server, X } from 'lucide-react';
import { formatBytes } from '../utils/formatters';
interface DownloadModalProps {
isOpen: boolean;
onClose: () => void;
}
const DownloadModal: React.FC<DownloadModalProps> = ({ isOpen, onClose }) => {
const [sizes, setSizes] = useState({ full: 0, backend: 0 });
useEffect(() => {
if (isOpen) {
// Fetch sizes
const fetchSize = async (url: string, key: 'full' | 'backend') => {
try {
const res = await fetch(url, { method: 'HEAD' });
const len = res.headers.get('content-length');
if (len) setSizes(prev => ({ ...prev, [key]: parseInt(len, 10) }));
} catch (e) {
console.error("Failed to fetch size", e);
}
};
fetchSize(`${import.meta.env.BASE_URL}webftp.exe`, 'full');
fetchSize(`${import.meta.env.BASE_URL}webftp-backend.exe`, 'backend');
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-xl w-[500px] max-w-full m-4 overflow-hidden animate-in fade-in zoom-in duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 bg-slate-50">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Download size={20} className="text-blue-600" />
WebZilla
</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<p className="text-sm text-slate-500 mb-4">
.
</p>
<div className="grid gap-4">
{/* Option 1: Full Version */}
<a
href={`${import.meta.env.BASE_URL}webftp.exe`}
download="webftp.exe"
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 hover:border-blue-400 hover:bg-blue-50 transition-all group relative"
>
<div className="p-3 bg-blue-100 text-blue-600 rounded-lg group-hover:bg-blue-200 transition-colors">
<Package size={24} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="font-bold text-slate-800"> (All-in-One)</h4>
<span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded text-slate-500">
{sizes.full > 0 ? formatBytes(sizes.full) : 'Loading...'}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">
, .
<span className="block text-blue-600 mt-1 font-semibold"> .</span>
</p>
</div>
<Download size={16} className="absolute top-4 right-4 text-slate-300 group-hover:text-blue-500" />
</a>
{/* Option 2: Backend Only */}
<a
href={`${import.meta.env.BASE_URL}webftp-backend.exe`}
download="webftp-backend.exe"
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 hover:border-emerald-400 hover:bg-emerald-50 transition-all group relative"
>
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-lg group-hover:bg-emerald-200 transition-colors">
<Server size={24} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="font-bold text-slate-800"> (Backend Only)</h4>
<span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded text-slate-500">
{sizes.backend > 0 ? formatBytes(sizes.backend) : 'Loading...'}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">
, .
( )
</p>
</div>
<Download size={16} className="absolute top-4 right-4 text-slate-300 group-hover:text-emerald-500" />
</a>
</div>
</div>
{/* Footer */}
<div className="px-4 py-3 bg-slate-50 border-t border-slate-100 text-right">
<button
onClick={onClose}
className="px-4 py-2 bg-white border border-slate-300 text-slate-700 rounded hover:bg-slate-50 text-sm font-medium shadow-sm"
>
</button>
</div>
</div>
</div>
);
};
export default DownloadModal;

View File

@@ -9,15 +9,31 @@ interface CreateFolderModalProps {
} }
export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, onClose, onConfirm }) => { export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, onClose, onConfirm }) => {
const [folderName, setFolderName] = useState('새 폴더'); const [folderName, setFolderName] = useState('');
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setFolderName('새 폴더'); setFolderName('');
// Auto focus hack setError('');
setTimeout(() => document.getElementById('new-folder-input')?.focus(), 50); // Auto focus hack
setTimeout(() => document.getElementById('new-folder-input')?.focus(), 50);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
} }
}, [isOpen]); }, [isOpen, onClose]);
const handleConfirm = () => {
if (!folderName.trim()) {
setError('폴더 이름을 입력해주세요.');
return;
}
onConfirm(folderName);
};
if (!isOpen) return null; if (!isOpen) return null;
@@ -37,14 +53,15 @@ export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, on
id="new-folder-input" id="new-folder-input"
type="text" type="text"
value={folderName} value={folderName}
onChange={(e) => setFolderName(e.target.value)} onChange={(e) => { setFolderName(e.target.value); setError(''); }}
onKeyDown={(e) => e.key === 'Enter' && onConfirm(folderName)} onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
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" className={`w-full bg-white border rounded px-3 py-2 text-sm text-slate-800 focus:outline-none ${error ? 'border-red-500 focus:border-red-500' : 'border-slate-300 focus:border-blue-500'}`}
/> />
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div> </div>
<div className="flex justify-end gap-2"> <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={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> <button onClick={handleConfirm} 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>
</div> </div>
@@ -62,13 +79,33 @@ interface RenameModalProps {
export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, onClose, onConfirm }) => { export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, onClose, onConfirm }) => {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setNewName(currentName); setNewName(currentName);
setTimeout(() => document.getElementById('rename-input')?.focus(), 50); setError('');
setTimeout(() => document.getElementById('rename-input')?.focus(), 50);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
} }
}, [isOpen, currentName]); }, [isOpen, currentName, onClose]);
const handleConfirm = () => {
if (!newName.trim()) {
setError('새 이름을 입력해주세요.');
return;
}
if (newName.trim() === currentName) {
setError('변경된 내용이 없습니다.');
return;
}
onConfirm(newName);
};
if (!isOpen) return null; if (!isOpen) return null;
@@ -91,14 +128,15 @@ export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, o
id="rename-input" id="rename-input"
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => { setNewName(e.target.value); setError(''); }}
onKeyDown={(e) => e.key === 'Enter' && onConfirm(newName)} onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
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" className={`w-full bg-white border rounded px-3 py-2 text-sm text-slate-800 focus:outline-none ${error ? 'border-red-500 focus:border-red-500' : 'border-slate-300 focus:border-blue-500'}`}
/> />
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div> </div>
<div className="flex justify-end gap-2"> <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={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> <button onClick={handleConfirm} 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>
</div> </div>
@@ -116,6 +154,16 @@ interface DeleteModalProps {
} }
export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fileNames, onClose, onConfirm }) => { export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fileNames, onClose, onConfirm }) => {
useEffect(() => {
if (isOpen) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -129,18 +177,18 @@ export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fil
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex gap-3"> <div className="flex gap-3">
<div className="bg-red-50 p-2 rounded h-fit shrink-0"> <div className="bg-red-50 p-2 rounded h-fit shrink-0">
<AlertTriangle size={24} className="text-red-500" /> <AlertTriangle size={24} className="text-red-500" />
</div> </div>
<div className="text-sm text-slate-600 min-w-0"> <div className="text-sm text-slate-600 min-w-0">
<p className="mb-2"> {fileCount} ?</p> <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"> <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) => ( {fileNames.map((name, i) => (
<li key={i} className="truncate">{name}</li> <li key={i} className="truncate">{name}</li>
))} ))}
</ul> </ul>
<p className="text-xs text-red-500 font-semibold"> .</p> <p className="text-xs text-red-500 font-semibold"> .</p>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <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={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>

View File

@@ -15,6 +15,7 @@ interface FilePaneProps {
onSelectionChange: (ids: Set<string>) => void; onSelectionChange: (ids: Set<string>) => void;
selectedIds: Set<string>; selectedIds: Set<string>;
connected?: boolean; connected?: boolean;
refreshKey?: number;
onCreateFolder?: () => void; onCreateFolder?: () => void;
onDelete?: () => void; onDelete?: () => void;
onRename?: () => void; onRename?: () => void;
@@ -31,12 +32,28 @@ const FilePane: React.FC<FilePaneProps> = ({
onSelectionChange, onSelectionChange,
selectedIds, selectedIds,
connected = true, connected = true,
refreshKey,
onCreateFolder, onCreateFolder,
onDelete, onDelete,
onRename onRename
}) => { }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedId, setLastClickedId] = useState<string | null>(null); const [lastClickedId, setLastClickedId] = useState<string | null>(null);
const [pathInput, setPathInput] = useState(path);
// Sync path input when prop changes OR refreshKey updates
React.useEffect(() => {
setPathInput(path);
}, [path, refreshKey]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onNavigate(pathInput);
} else if (e.key === 'Escape') {
setPathInput(path); // Revert
(e.target as HTMLInputElement).blur();
}
};
// Filter files based on search term // Filter files based on search term
const displayFiles = useMemo(() => { const displayFiles = useMemo(() => {
@@ -69,7 +86,7 @@ const FilePane: React.FC<FilePaneProps> = ({
newSelected = new Set(); newSelected = new Set();
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
newSelected.add(displayFiles[i].id); newSelected.add(displayFiles[i].id);
} }
} }
} else { } else {
@@ -82,16 +99,23 @@ const FilePane: React.FC<FilePaneProps> = ({
}; };
return ( return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm"> <div className={`flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm transition-all duration-300 ${!connected ? 'opacity-60 grayscale-[0.5] pointer-events-none' : ''}`}>
{/* Header */} {/* Header */}
<div className="bg-slate-50 p-2 border-b border-slate-200 flex items-center justify-between"> <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"> <div className="flex items-center gap-2 text-slate-700 font-semibold text-sm shrink-0">
{icon === 'local' ? <Monitor size={16} /> : <Server size={16} />} {icon === 'local' ? <Monitor size={16} /> : <Server size={16} />}
<span>{title}</span> <span>{title}</span>
</div> </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"> <div className="flex items-center gap-2 bg-white px-2 py-0.5 rounded border border-slate-200 text-xs text-slate-500 flex-1 ml-4 shadow-sm focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-all">
<span className="text-slate-400">:</span> <span className="text-slate-400 shrink-0">:</span>
<span className="font-mono text-slate-700 select-all">{path}</span> <input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathKeyDown}
className="font-mono text-slate-700 w-full outline-none text-xs py-1"
spellCheck={false}
/>
</div> </div>
</div> </div>
@@ -131,10 +155,10 @@ const FilePane: React.FC<FilePaneProps> = ({
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
<button <button
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors"
title="새로고침" title="새로고침"
onClick={() => onNavigate(path)} // Simple refresh onClick={() => onNavigate(path)} // Simple refresh
disabled={!connected || isLoading} disabled={!connected || isLoading}
> >
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} /> <RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
</button> </button>
@@ -143,14 +167,14 @@ const FilePane: React.FC<FilePaneProps> = ({
{/* Search Input */} {/* Search Input */}
<div className="relative group"> <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" /> <Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색..." 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" 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>
</div> </div>
@@ -193,46 +217,45 @@ const FilePane: React.FC<FilePaneProps> = ({
{displayFiles.map((file) => { {displayFiles.map((file) => {
const isSelected = selectedIds.has(file.id); const isSelected = selectedIds.has(file.id);
return ( return (
<tr <tr
key={file.id} key={file.id}
onClick={(e) => handleRowClick(e, file)} onClick={(e) => handleRowClick(e, file)}
onDoubleClick={() => { onDoubleClick={() => {
if (file.type === FileType.FOLDER) { if (file.type === FileType.FOLDER) {
onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`); onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`);
onSelectionChange(new Set()); // Clear selection on navigate onSelectionChange(new Set()); // Clear selection on navigate
setSearchTerm(''); // Clear search on navigate setSearchTerm(''); // Clear search on navigate
} }
}} }}
className={`cursor-pointer border-b border-slate-50 group select-none ${ className={`cursor-pointer border-b border-slate-50 group select-none ${isSelected
isSelected ? 'bg-blue-100 text-blue-900 border-blue-200'
? 'bg-blue-100 text-blue-900 border-blue-200' : 'text-slate-700 hover:bg-slate-50'
: 'text-slate-700 hover:bg-slate-50' }`}
}`} >
>
<td className="p-2 text-center"> <td className="p-2 text-center">
<FileIcon name={file.name} type={file.type} className="w-4 h-4" /> <FileIcon name={file.name} type={file.type} className="w-4 h-4" />
</td> </td>
<td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]"> <td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]">
{file.name} {file.name}
</td> </td>
<td className="p-2 text-right font-mono text-slate-500"> <td className="p-2 text-right font-mono text-slate-500">
{file.type === FileType.FILE ? formatBytes(file.size) : ''} {file.type === FileType.FILE ? formatBytes(file.size) : ''}
</td> </td>
<td className="p-2 text-right text-slate-400 hidden lg:table-cell"> <td className="p-2 text-right text-slate-400 hidden lg:table-cell">
{file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'} {file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'}
</td> </td>
<td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap"> <td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap">
{formatDate(file.date).split(',')[0]} {formatDate(file.date).split(',')[0]}
</td> </td>
</tr> </tr>
); );
})} })}
{displayFiles.length === 0 && ( {displayFiles.length === 0 && (
<tr> <tr>
<td colSpan={5} className="p-8 text-center text-slate-400"> <td colSpan={5} className="p-8 text-center text-slate-400">
{searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'} {searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'}
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>

228
components/HelpModal.tsx Normal file
View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { X, HelpCircle, Server, Folder, FileText, Settings, Wifi, Terminal } from 'lucide-react';
interface HelpModalProps {
isOpen: boolean;
onClose: () => void;
initialTab?: 'sites' | 'connection' | 'files' | 'backend';
}
const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose, initialTab }) => {
const [activeTab, setActiveTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
useEffect(() => {
if (isOpen && initialTab) {
setActiveTab(initialTab);
}
}, [isOpen, initialTab]);
// ESC key handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isOpen && (e.key === 'Escape' || e.key === 'Esc')) {
onClose();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
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-2xl flex flex-col h-[600px] max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200 bg-slate-50 rounded-t-lg">
<h2 className="text-base font-bold text-slate-800 flex items-center gap-2">
<HelpCircle 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>
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-48 border-r border-slate-200 bg-slate-50 p-2 flex flex-col gap-1">
<button
onClick={() => setActiveTab('sites')}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded transition-colors ${activeTab === 'sites' ? 'bg-white text-blue-600 shadow-sm font-medium' : 'text-slate-600 hover:bg-slate-200'
}`}
>
<Server size={16} />
</button>
<button
onClick={() => setActiveTab('connection')}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded transition-colors ${activeTab === 'connection' ? 'bg-white text-blue-600 shadow-sm font-medium' : 'text-slate-600 hover:bg-slate-200'
}`}
>
<Wifi size={16} />
</button>
<button
onClick={() => setActiveTab('backend')}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded transition-colors ${activeTab === 'backend' ? 'bg-white text-blue-600 shadow-sm font-medium' : 'text-slate-600 hover:bg-slate-200'
}`}
>
<Terminal size={16} /> /
</button>
<button
onClick={() => setActiveTab('files')}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded transition-colors ${activeTab === 'files' ? 'bg-white text-blue-600 shadow-sm font-medium' : 'text-slate-600 hover:bg-slate-200'
}`}
>
<Folder size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-white">
{activeTab === 'sites' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
<Server size={20} className="text-slate-400" />
</h3>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
FTP .
</p>
<ul className="list-disc list-inside text-sm text-slate-600 space-y-2 bg-slate-50 p-4 rounded border border-slate-100">
<li><strong> :</strong> .</li>
<li><strong> :</strong> . (: /public_html)</li>
<li><strong> :</strong> '연결' . ( )</li>
</ul>
</div>
</div>
)}
{activeTab === 'connection' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
<Wifi size={20} className="text-slate-400" />
</h3>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
.
</p>
<ul className="list-disc list-inside text-sm text-slate-600 space-y-2 bg-slate-50 p-4 rounded border border-slate-100">
<li><strong> :</strong> , , .</li>
<li><strong> :</strong> (/) .</li>
<li><strong> :</strong> .</li>
</ul>
</div>
</div>
)}
{activeTab === 'backend' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
<Terminal size={20} className="text-slate-400" />
</h3>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
WebZilla는 .
</p>
<div className="space-y-4">
<div className="bg-amber-50 p-4 rounded border border-amber-100">
<h4 className="font-bold text-amber-800 text-sm mb-2 flex items-center gap-2">
<Settings size={16} /> 1.
</h4>
<p className="text-xs text-amber-700 leading-relaxed">
<span className="font-bold bg-emerald-600 text-white px-1.5 py-0.5 rounded text-[10px]"></span>
<code className="mx-1 bg-amber-100 px-1 rounded text-amber-900">backend_proxy.cjs</code> .
</p>
</div>
<div className="bg-slate-50 p-4 rounded border border-slate-200">
<h4 className="font-bold text-slate-800 text-sm mb-2 text- mb-2">2. </h4>
<div className="space-y-3">
<div>
<span className="text-xs font-bold text-slate-600 block mb-1"> A: Node.js가 ()</span>
<div className="bg-slate-800 text-slate-200 p-2 rounded text-xs font-mono">
node backend_proxy.cjs
</div>
</div>
<div>
<span className="text-xs font-bold text-slate-600 block mb-1"> B: 실행 (.exe) </span>
<p className="text-xs text-slate-500"> exe .</p>
</div>
</div>
</div>
<div className="flex items-start gap-2 text-xs text-blue-600 bg-blue-50 p-3 rounded">
<div className="shrink-0 mt-0.5"><Wifi size={14} /></div>
<p> <strong>8090</strong> , 'Server' <span className="font-bold text-green-600">Connected</span> .</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'files' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
<Folder size={20} className="text-slate-400" />
</h3>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
( ) () .
</p>
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded border border-blue-100">
<h4 className="font-bold text-blue-800 text-sm mb-1"> </h4>
<p className="text-xs text-blue-600">
/ . ( )
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="border border-slate-200 p-3 rounded">
<h4 className="font-bold text-slate-700 text-sm mb-1"> ( )</h4>
<ul className="text-xs text-slate-500 space-y-1">
<li> </li>
<li> </li>
<li> / </li>
</ul>
</div>
<div className="border border-slate-200 p-3 rounded">
<h4 className="font-bold text-slate-700 text-sm mb-1"> ()</h4>
<ul className="text-xs text-slate-500 space-y-1">
<li> (MKD)</li>
<li> (RENAME)</li>
<li> / (DELE)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 flex justify-end rounded-b-lg">
<button
onClick={onClose}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded shadow-sm transition-colors"
>
</button>
</div>
</div>
</div>
);
};
export default HelpModal;

View File

@@ -6,177 +6,45 @@ interface SettingsModalProps {
onClose: () => void; onClose: () => void;
} }
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => { const SettingsModal: React.FC<SettingsModalProps & { saveConnectionInfo: boolean, onToggleSaveConnectionInfo: (checked: boolean) => void }> = ({
const [activeTab, setActiveTab] = useState<'arch' | 'code'>('arch'); isOpen,
const [copied, setCopied] = useState(false); onClose,
saveConnectionInfo,
onToggleSaveConnectionInfo
}) => {
if (!isOpen) return null; 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: 8090 });
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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-2xl flex flex-col max-h-[85vh]"> <div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-md flex flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200"> <div className="flex items-center justify-between p-4 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2"> <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Server size={20} className="text-blue-600" /> <Server size={20} className="text-blue-600" />
</h2> </h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors"> <button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
<X size={20} /> <X size={20} />
</button> </button>
</div> </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 */} {/* Content */}
<div className="p-6 overflow-y-auto flex-1 text-slate-600"> <div className="p-6">
{activeTab === 'arch' ? ( <label className="flex items-center gap-3 p-4 border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">
<div className="space-y-6"> <div className="relative flex items-center">
<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"> <input
<div className="flex flex-col items-center gap-2"> type="checkbox"
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center border border-blue-200"> checked={saveConnectionInfo}
<Globe size={32} className="text-blue-500" /> onChange={(e) => onToggleSaveConnectionInfo(e.target.checked)}
</div> className="w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
<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>
) : ( <div className="flex-1">
<div className="space-y-4"> <span className="font-semibold text-slate-700 block text-sm"> </span>
<div className="flex items-center justify-between"> <span className="text-xs text-slate-500">, , .</span>
<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>
)} </label>
</div> </div>
<div className="p-4 border-t border-slate-200 bg-slate-50 rounded-b-lg flex justify-end"> <div className="p-4 border-t border-slate-200 bg-slate-50 rounded-b-lg flex justify-end">

View File

@@ -97,8 +97,8 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
// Auto port update based on protocol // Auto port update based on protocol
if (field === 'protocol') { if (field === 'protocol') {
if (value === 'sftp') updated.port = '22'; if (value === 'sftp') updated.port = '22';
if (value === 'ftp') updated.port = '21'; if (value === 'ftp') updated.port = '21';
} }
setFormData(updated); setFormData(updated);
@@ -151,11 +151,10 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
<div <div
key={site.id} key={site.id}
onClick={() => selectSite(site)} onClick={() => selectSite(site)}
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${ className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${selectedId === site.id
selectedId === site.id
? 'bg-blue-100 text-blue-900 border border-blue-200' ? 'bg-blue-100 text-blue-900 border border-blue-200'
: 'text-slate-600 hover:bg-slate-200' : 'text-slate-600 hover:bg-slate-200'
}`} }`}
> >
<Server size={14} className={selectedId === site.id ? 'text-blue-600' : 'text-slate-400'} /> <Server size={14} className={selectedId === site.id ? 'text-blue-600' : 'text-slate-400'} />
<span className="truncate">{site.name}</span> <span className="truncate">{site.name}</span>
@@ -175,128 +174,137 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
<> <>
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-slate-200 px-4"> <div className="flex border-b border-slate-200 px-4">
<button <button
onClick={() => setActiveTab('general')} onClick={() => setActiveTab('general')}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${ 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'
activeTab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500' }`}
}`} >
> (General)
(General) </button>
</button> <button
<button onClick={() => setActiveTab('transfer')}
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'
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>
(Transfer)
</button>
</div> </div>
<div className="p-6 flex-1 overflow-y-auto"> <div className="p-6 flex-1 overflow-y-auto">
{activeTab === 'general' ? ( {activeTab === 'general' ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-4 gap-4 items-center"> <div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"> </label> <label className="text-xs text-slate-500 text-right"> </label>
<input <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" type="text"
value={formData.name} value={formData.host}
onChange={(e) => updateForm('name', e.target.value)} onChange={(e) => updateForm('host', 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" 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> />
<div className="flex items-center gap-2">
<hr className="border-slate-200 my-4" /> <span className="text-xs text-slate-500">:</span>
<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 <input
type="text" type="text"
value={formData.host} value={formData.port}
onChange={(e) => updateForm('host', e.target.value)} onChange={(e) => updateForm('port', e.target.value)}
placeholder="ftp.example.com" 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"
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"> </div>
<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>
</div>
<div className="grid grid-cols-4 gap-4 items-center"> <div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"> (ID)</label> <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 className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"> </label>
<input
type="text"
value={formData.initialPath || ''}
onChange={(e) => updateForm('initialPath', 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 <input
type="text" type="checkbox"
value={formData.user} checked={formData.passiveMode !== false}
onChange={(e) => updateForm('user', e.target.value)} onChange={(e) => updateForm('passiveMode', e.target.checked)}
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" className="hidden"
/> />
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors"> (Passive Mode) </div>
</label>
</div> </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"> <div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"></label> <label className="text-xs text-slate-500"> :</label>
<input <input type="number" defaultValue={2} disabled className="bg-slate-100 border border-slate-300 rounded px-2 py-1 text-sm text-slate-500" />
type="password" <span className="text-xs text-slate-400 col-span-2">( )</span>
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> </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>
</> </>
) : ( ) : (
@@ -312,8 +320,8 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
{/* Footer Actions */} {/* Footer Actions */}
<div className="p-3 border-t border-slate-200 bg-slate-50 flex justify-between items-center rounded-b-lg"> <div className="p-3 border-t border-slate-200 bg-slate-50 flex justify-between items-center rounded-b-lg">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors" className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors"
> >
</button> </button>
@@ -322,11 +330,10 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!formData} disabled={!formData}
className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${ className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${isDirty
isDirty
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20' ? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20'
: 'bg-white border border-slate-300 text-slate-500' : 'bg-white border border-slate-300 text-slate-500'
}`} }`}
> >
<Save size={16} /> <Save size={16} />
</button> </button>

View File

@@ -1,19 +1,60 @@
import React, { useEffect, useRef } from 'react'; import React, { useState } from 'react';
import { TransferItem } from '../types'; import { TransferItem } from '../types';
import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock } from 'lucide-react'; import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock, Trash2 } from 'lucide-react';
interface TransferQueueProps { interface TransferQueueProps {
queue: TransferItem[]; queue: TransferItem[];
onCancelAll: () => void;
onClearCompleted: () => void;
} }
const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => { const TransferQueue: React.FC<TransferQueueProps> = ({ queue, onCancelAll, onClearCompleted }) => {
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
const filteredQueue = queue.filter(item => {
if (activeTab === 'active') {
return item.status === 'queued' || item.status === 'transferring';
} else {
return item.status === 'completed' || item.status === 'failed';
}
});
return ( return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm"> <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"> <div className="bg-slate-50 px-3 py-1 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="flex space-x-2">
<div className="text-xs text-slate-500"> <button
{queue.filter(i => i.status === 'transferring').length} className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'active' ? 'bg-white text-blue-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
onClick={() => setActiveTab('active')}
>
({queue.filter(i => i.status === 'queued' || i.status === 'transferring').length})
</button>
<button
className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'completed' ? 'bg-white text-emerald-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
onClick={() => setActiveTab('completed')}
>
({queue.filter(i => i.status === 'completed' || i.status === 'failed').length})
</button>
</div> </div>
{activeTab === 'active' && filteredQueue.length > 0 && (
<button
onClick={onCancelAll}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded transition-colors"
>
<Trash2 size={12} />
</button>
)}
{activeTab === 'completed' && filteredQueue.length > 0 && (
<button
onClick={onClearCompleted}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:bg-slate-200 rounded transition-colors"
>
<Trash2 size={12} />
</button>
)}
</div> </div>
<div className="flex-1 overflow-y-auto bg-white"> <div className="flex-1 overflow-y-auto bg-white">
@@ -22,13 +63,16 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
<tr> <tr>
<th className="p-2 w-8"></th> <th className="p-2 w-8"></th>
<th className="p-2"></th> <th className="p-2"></th>
<th className="p-2 w-24"></th> <th className="p-2 w-20"></th>
<th className="p-2 w-48"></th> <th className="p-2 w-40 text-center"></th>
<th className="p-2 w-40 text-center"></th>
<th className="p-2 w-32 text-center"></th>
<th className="p-2 w-32"></th>
<th className="p-2 w-24 text-right"></th> <th className="p-2 w-24 text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{queue.map((item) => ( {filteredQueue.map((item) => (
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700"> <tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
<td className="p-2 text-center"> <td className="p-2 text-center">
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />} {item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
@@ -38,20 +82,28 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div> <div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
)} )}
</td> </td>
<td className="p-2 truncate max-w-[200px] font-medium">{item.filename}</td> <td className="p-2 truncate max-w-[150px] font-medium" title={item.filename}>{item.filename}</td>
<td className="p-2"> <td className="p-2">
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}> <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' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
{item.direction === 'upload' ? '업로드' : '다운로드'} {item.direction === 'upload' ? '업로드' : '다운로드'}
</span> </span>
</td> </td>
<td className="p-2 text-center text-xs text-slate-500">
{item.requestedAt ? new Date(item.requestedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
</td>
<td className="p-2 text-center text-xs text-slate-500">
{item.completedAt ? new Date(item.completedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
</td>
<td className="p-2 text-center text-xs text-slate-500">
{item.completedAt && item.requestedAt ? `${((item.completedAt - item.requestedAt) / 1000).toFixed(1)}s` : '-'}
</td>
<td className="p-2"> <td className="p-2">
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200"> <div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
<div <div
className={`h-full transition-all duration-200 ${ className={`h-full transition-all duration-200 ${item.status === 'completed' ? 'bg-emerald-500' :
item.status === 'completed' ? 'bg-emerald-500' :
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500' item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
}`} }`}
style={{ width: `${item.progress}%` }} style={{ width: `${item.progress}%` }}
/> />
</div> </div>
@@ -59,10 +111,10 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td> <td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
</tr> </tr>
))} ))}
{queue.length === 0 && ( {filteredQueue.length === 0 && (
<tr> <tr>
<td colSpan={5} className="p-8 text-center text-slate-400 italic"> <td colSpan={8} className="p-8 text-center text-slate-400 italic">
. {activeTab === 'active' ? '대기 중인 전송 파일이 없습니다.' : '완료된 전송 내역이 없습니다.'}
</td> </td>
</tr> </tr>
)} )}

View File

@@ -1,10 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebZilla - Cloud FTP Client</title> <title>WebFTP by SIMP</title>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4444852135420953"
crossorigin="anonymous"></script>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
/* Custom scrollbar for a more native app feel - Light Theme */ /* Custom scrollbar for a more native app feel - Light Theme */

View File

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

1772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,13 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"bin": "backend_proxy.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "clean": "rimraf public/webftp.exe public/webftp-backend.exe dist/webftp.exe dist/webftp-backend.exe",
"build": "npm run clean && vite build && npm run build:backend && npm run build:full",
"build:full": "pkg . --output ./public/webftp.exe",
"build:backend": "pkg backend_proxy.cjs -c pkg-backend.json --output ./public/webftp-backend.exe",
"preview": "vite preview", "preview": "vite preview",
"proxy": "node backend_proxy.cjs" "proxy": "node backend_proxy.cjs"
}, },
@@ -20,7 +24,18 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"pkg": "^5.8.1",
"rimraf": "^6.1.2",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
},
"pkg": {
"scripts": "backend_proxy.cjs",
"assets": [
"dist/**/*"
],
"targets": [
"node18-win-x64"
]
} }
} }

12
pkg-backend.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "webzilla-backend",
"version": "0.0.0",
"bin": "backend_proxy.cjs",
"pkg": {
"scripts": "backend_proxy.cjs",
"assets": [],
"targets": [
"node18-win-x64"
]
}
}

View File

@@ -27,6 +27,8 @@ export interface TransferItem {
progress: number; // 0 to 100 progress: number; // 0 to 100
status: 'queued' | 'transferring' | 'completed' | 'failed'; status: 'queued' | 'transferring' | 'completed' | 'failed';
speed: string; speed: string;
requestedAt?: number; // Timestamp
completedAt?: number; // Timestamp
} }
export interface FileSystemState { export interface FileSystemState {
@@ -44,4 +46,5 @@ export interface SiteConfig {
user: string; user: string;
pass?: string; // Optional for security pass?: string; // Optional for security
passiveMode?: boolean; passiveMode?: boolean;
initialPath?: string;
} }

Binary file not shown.