Compare commits
20 Commits
c485f411b3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31b3c6d31 | ||
|
|
fc9500f99b | ||
|
|
c84fd2a7e9 | ||
|
|
a1a1971a1f | ||
|
|
b3a6d74f1e | ||
|
|
467d0f5917 | ||
| ea070816e3 | |||
|
|
86729014d3 | ||
|
|
e28de5f2c3 | ||
|
|
32fee75e69 | ||
|
|
237ed6ea8b | ||
| 4376babbcc | |||
|
|
37bb2890ce | ||
|
|
cbeb55daf1 | ||
|
|
3acb952174 | ||
|
|
c86fd099ee | ||
|
|
b01822c998 | ||
|
|
79d161d7f7 | ||
|
|
253f6c4fd5 | ||
|
|
5fd84a7ff1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
public/webftp-backend.exe
|
||||||
|
public/webftp.exe
|
||||||
|
|||||||
@@ -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();
|
||||||
104
components/ConflictModal.tsx
Normal file
104
components/ConflictModal.tsx
Normal 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;
|
||||||
119
components/DownloadModal.tsx
Normal file
119
components/DownloadModal.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -33,18 +49,19 @@ export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, on
|
|||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 mb-1">새 디렉토리 이름:</label>
|
<label className="block text-xs text-slate-500 mb-1">새 디렉토리 이름:</label>
|
||||||
<input
|
<input
|
||||||
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;
|
||||||
|
|
||||||
@@ -85,20 +122,21 @@ export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, o
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 mb-1">현재 이름:</label>
|
<label className="block text-xs text-slate-500 mb-1">현재 이름:</label>
|
||||||
<div className="text-sm text-slate-600 bg-slate-50 p-2 rounded border border-slate-200 mb-3 select-all">{currentName}</div>
|
<div className="text-sm text-slate-600 bg-slate-50 p-2 rounded border border-slate-200 mb-3 select-all">{currentName}</div>
|
||||||
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">새 이름:</label>
|
<label className="block text-xs text-slate-500 mb-1">새 이름:</label>
|
||||||
<input
|
<input
|
||||||
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>
|
||||||
|
|||||||
@@ -15,28 +15,45 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilePane: React.FC<FilePaneProps> = ({
|
const FilePane: React.FC<FilePaneProps> = ({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
path,
|
path,
|
||||||
files,
|
files,
|
||||||
isLoading,
|
isLoading,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onNavigateUp,
|
onNavigateUp,
|
||||||
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(() => {
|
||||||
@@ -46,7 +63,7 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
|
|
||||||
const handleRowClick = (e: React.MouseEvent, file: FileItem) => {
|
const handleRowClick = (e: React.MouseEvent, file: FileItem) => {
|
||||||
e.preventDefault(); // Prevent text selection
|
e.preventDefault(); // Prevent text selection
|
||||||
|
|
||||||
let newSelected = new Set(selectedIds);
|
let newSelected = new Set(selectedIds);
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -61,15 +78,15 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
// Range selection
|
// Range selection
|
||||||
const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId);
|
const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId);
|
||||||
const currentIndex = displayFiles.findIndex(f => f.id === file.id);
|
const currentIndex = displayFiles.findIndex(f => f.id === file.id);
|
||||||
|
|
||||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||||
const start = Math.min(lastIndex, currentIndex);
|
const start = Math.min(lastIndex, currentIndex);
|
||||||
const end = Math.max(lastIndex, currentIndex);
|
const end = Math.max(lastIndex, currentIndex);
|
||||||
|
|
||||||
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,22 +99,29 @@ 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>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center">
|
<div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center">
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateUp}
|
onClick={onNavigateUp}
|
||||||
disabled={path === '/'}
|
disabled={path === '/'}
|
||||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -106,7 +130,7 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-4 bg-slate-200 mx-1"></div>
|
<div className="w-px h-4 bg-slate-200 mx-1"></div>
|
||||||
<button
|
<button
|
||||||
onClick={onCreateFolder}
|
onClick={onCreateFolder}
|
||||||
disabled={!connected || isLoading}
|
disabled={!connected || isLoading}
|
||||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -114,7 +138,7 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
>
|
>
|
||||||
<FolderPlus size={16} />
|
<FolderPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onRename}
|
onClick={onRename}
|
||||||
disabled={selectedIds.size !== 1 || !connected || isLoading}
|
disabled={selectedIds.size !== 1 || !connected || isLoading}
|
||||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -122,7 +146,7 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
>
|
>
|
||||||
<FilePenLine size={16} />
|
<FilePenLine size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={selectedIds.size === 0 || !connected || isLoading}
|
disabled={selectedIds.size === 0 || !connected || isLoading}
|
||||||
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -130,27 +154,27 @@ 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>
|
||||||
|
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|
||||||
@@ -179,7 +203,7 @@ const FilePane: React.FC<FilePaneProps> = ({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{/* Back Button Row */}
|
{/* Back Button Row */}
|
||||||
{path !== '/' && !searchTerm && (
|
{path !== '/' && !searchTerm && (
|
||||||
<tr
|
<tr
|
||||||
className="hover:bg-slate-50 cursor-pointer text-slate-500"
|
className="hover:bg-slate-50 cursor-pointer text-slate-500"
|
||||||
onClick={onNavigateUp}
|
onClick={onNavigateUp}
|
||||||
>
|
>
|
||||||
@@ -193,52 +217,51 @@ 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Status */}
|
{/* Footer Status */}
|
||||||
<div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between">
|
<div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between">
|
||||||
<span>{files.length} 개 항목 {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span>
|
<span>{files.length} 개 항목 {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span>
|
||||||
|
|||||||
228
components/HelpModal.tsx
Normal file
228
components/HelpModal.tsx
Normal 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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ interface SiteManagerModalProps {
|
|||||||
initialSites: SiteConfig[];
|
initialSites: SiteConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConnect,
|
onConnect,
|
||||||
onSaveSites,
|
onSaveSites,
|
||||||
initialSites
|
initialSites
|
||||||
}) => {
|
}) => {
|
||||||
const [sites, setSites] = useState<SiteConfig[]>([]);
|
const [sites, setSites] = useState<SiteConfig[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -60,11 +60,11 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (!window.confirm('선택한 사이트를 삭제하시겠습니까?')) return;
|
if (!window.confirm('선택한 사이트를 삭제하시겠습니까?')) return;
|
||||||
|
|
||||||
const newSites = sites.filter(s => s.id !== selectedId);
|
const newSites = sites.filter(s => s.id !== selectedId);
|
||||||
setSites(newSites);
|
setSites(newSites);
|
||||||
onSaveSites(newSites); // Auto save on delete
|
onSaveSites(newSites); // Auto save on delete
|
||||||
|
|
||||||
if (newSites.length > 0) {
|
if (newSites.length > 0) {
|
||||||
selectSite(newSites[0]);
|
selectSite(newSites[0]);
|
||||||
} else {
|
} else {
|
||||||
@@ -75,7 +75,7 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!formData) return;
|
if (!formData) return;
|
||||||
|
|
||||||
const newSites = sites.map(s => s.id === formData.id ? formData : s);
|
const newSites = sites.map(s => s.id === formData.id ? formData : s);
|
||||||
setSites(newSites);
|
setSites(newSites);
|
||||||
onSaveSites(newSites);
|
onSaveSites(newSites);
|
||||||
@@ -94,19 +94,19 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
const updateForm = (field: keyof SiteConfig, value: any) => {
|
const updateForm = (field: keyof SiteConfig, value: any) => {
|
||||||
if (!formData) return;
|
if (!formData) return;
|
||||||
const updated = { ...formData, [field]: value };
|
const updated = { ...formData, [field]: value };
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
setSites(sites.map(s => s.id === updated.id ? updated : s));
|
setSites(sites.map(s => s.id === updated.id ? updated : s));
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
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-3xl flex flex-col h-[600px] max-h-[90vh]">
|
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-3xl flex flex-col h-[600px] max-h-[90vh]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-slate-200 bg-slate-50 rounded-t-lg">
|
<div className="flex items-center justify-between p-3 border-b border-slate-200 bg-slate-50 rounded-t-lg">
|
||||||
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||||
@@ -131,13 +131,13 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
{/* Left: Site List */}
|
{/* Left: Site List */}
|
||||||
<div className="w-1/3 border-r border-slate-200 flex flex-col bg-slate-50">
|
<div className="w-1/3 border-r border-slate-200 flex flex-col bg-slate-50">
|
||||||
<div className="p-2 border-b border-slate-200 flex gap-2">
|
<div className="p-2 border-b border-slate-200 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleNewSite}
|
onClick={handleNewSite}
|
||||||
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 text-xs py-1.5 rounded border border-slate-300 flex items-center justify-center gap-1 transition-colors shadow-sm"
|
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 text-xs py-1.5 rounded border border-slate-300 flex items-center justify-center gap-1 transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
<FolderPlus size={14} /> 새 사이트
|
<FolderPlus size={14} /> 새 사이트
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
className="bg-white hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 text-xs px-2 py-1.5 rounded border border-slate-300 transition-colors disabled:opacity-50 shadow-sm"
|
className="bg-white hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 text-xs px-2 py-1.5 rounded border border-slate-300 transition-colors disabled:opacity-50 shadow-sm"
|
||||||
@@ -145,17 +145,16 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
{sites.map(site => (
|
{sites.map(site => (
|
||||||
<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"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => updateForm('name', e.target.value)}
|
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"
|
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-slate-200 my-4" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4 items-center">
|
||||||
|
<label className="text-xs text-slate-500 text-right">프로토콜</label>
|
||||||
|
<select
|
||||||
|
value={formData.protocol}
|
||||||
|
onChange={(e) => updateForm('protocol', e.target.value as any)}
|
||||||
|
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||||
|
>
|
||||||
|
<option value="ftp">FTP - 파일 전송 프로토콜</option>
|
||||||
|
<option value="sftp">SFTP - SSH 파일 전송 프로토콜</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4 items-center">
|
||||||
|
<label className="text-xs text-slate-500 text-right">호스트</label>
|
||||||
|
<div className="col-span-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.host}
|
||||||
|
onChange={(e) => updateForm('host', e.target.value)}
|
||||||
|
placeholder="ftp.example.com"
|
||||||
|
className="flex-1 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">포트:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.port}
|
||||||
|
onChange={(e) => updateForm('port', e.target.value)}
|
||||||
|
className="w-16 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm text-center focus:border-blue-500 focus:outline-none text-slate-800"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">사용자 (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">
|
<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>
|
||||||
<select
|
<input
|
||||||
value={formData.protocol}
|
type="password"
|
||||||
onChange={(e) => updateForm('protocol', e.target.value as any)}
|
value={formData.pass || ''}
|
||||||
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"
|
onChange={(e) => updateForm('pass', e.target.value)}
|
||||||
>
|
placeholder="저장하지 않으려면 비워두세요"
|
||||||
<option value="ftp">FTP - 파일 전송 프로토콜</option>
|
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"
|
||||||
<option value="sftp">SFTP - SSH 파일 전송 프로토콜</option>
|
/>
|
||||||
</select>
|
</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">호스트</label>
|
<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.initialPath || ''}
|
||||||
value={formData.host}
|
onChange={(e) => updateForm('initialPath', e.target.value)}
|
||||||
onChange={(e) => updateForm('host', e.target.value)}
|
placeholder="/ (기본값)"
|
||||||
placeholder="ftp.example.com"
|
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"
|
||||||
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>
|
||||||
|
) : (
|
||||||
|
<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="flex items-center gap-2">
|
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors">패시브 모드 (Passive Mode) 사용</div>
|
||||||
<span className="text-xs text-slate-500">포트:</span>
|
</label>
|
||||||
<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>
|
||||||
|
<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">사용자 (ID)</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="text"
|
<span className="text-xs text-slate-400 col-span-2">(데모 제한)</span>
|
||||||
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>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-4 items-center">
|
</div>
|
||||||
<label className="text-xs text-slate-500 text-right">비밀번호</label>
|
)}
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.pass || ''}
|
|
||||||
onChange={(e) => updateForm('pass', e.target.value)}
|
|
||||||
placeholder="저장하지 않으려면 비워두세요"
|
|
||||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">전송 모드</h3>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
|
||||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${formData.passiveMode !== false ? 'bg-blue-600 border-blue-500' : 'bg-white border-slate-400'}`}>
|
|
||||||
{formData.passiveMode !== false && <div className="w-2 h-2 bg-white rounded-sm" />}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.passiveMode !== false}
|
|
||||||
onChange={(e) => updateForm('passiveMode', e.target.checked)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors">패시브 모드 (Passive Mode) 사용</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 ml-6">
|
|
||||||
서버가 방화벽/NAT 뒤에 있는 경우 패시브 모드를 사용하는 것이 좋습니다.
|
|
||||||
(FTP 프로토콜에만 적용됩니다)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">제한 설정</h3>
|
|
||||||
<div className="grid grid-cols-4 gap-4 items-center">
|
|
||||||
<label className="text-xs text-slate-500">최대 연결 수:</label>
|
|
||||||
<input type="number" defaultValue={2} disabled className="bg-slate-100 border border-slate-300 rounded px-2 py-1 text-sm text-slate-500" />
|
|
||||||
<span className="text-xs text-slate-400 col-span-2">(데모 제한)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -311,26 +319,25 @@ 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>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
onClick={handleConnectClick}
|
onClick={handleConnectClick}
|
||||||
disabled={!formData}
|
disabled={!formData}
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-2 shadow-md shadow-blue-500/20"
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-2 shadow-md shadow-blue-500/20"
|
||||||
|
|||||||
@@ -1,34 +1,78 @@
|
|||||||
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">
|
||||||
<table className="w-full text-xs text-left border-collapse">
|
<table className="w-full text-xs text-left border-collapse">
|
||||||
<thead className="bg-slate-50 text-slate-500 sticky top-0 border-b border-slate-200">
|
<thead className="bg-slate-50 text-slate-500 sticky top-0 border-b border-slate-200">
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
2
make.bat
2
make.bat
@@ -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
1772
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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
12
pkg-backend.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
types.ts
3
types.ts
@@ -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.
Reference in New Issue
Block a user