feat: Add Help System, Local File Operations, Site Manager improvements, and UI refinements
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* WebZilla 백엔드 프록시 서버 (Node.js) v1.1
|
||||
* WebZilla 백엔드 프록시 서버 (Node.js) v1.2
|
||||
*
|
||||
* 기능 추가:
|
||||
* - 로컬 파일 시스템 접근 (fs)
|
||||
* - 설정 데이터 저장 (AppData/Roaming 또는 ~/.config)
|
||||
* 기능:
|
||||
* - WebSocket Proxy (Port: 8090)
|
||||
* - FTP/SFTP 지원
|
||||
* - 로컬 파일 시스템 탐색 (LOCAL_LIST)
|
||||
* - 설정 저장 (AppData)
|
||||
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
@@ -11,139 +14,296 @@ const ftp = require('basic-ftp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { exec } = require('child_process');
|
||||
const readline = require('readline');
|
||||
|
||||
// --- 로컬 저장소 경로 설정 (AppData 구현) ---
|
||||
function getConfigDir() {
|
||||
const homedir = os.homedir();
|
||||
|
||||
// Windows: C:\Users\User\AppData\Roaming\WebZilla
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
|
||||
}
|
||||
// macOS: ~/Library/Application Support/WebZilla
|
||||
else if (process.platform === 'darwin') {
|
||||
} else if (process.platform === 'darwin') {
|
||||
return path.join(homedir, 'Library', 'Application Support', 'WebZilla');
|
||||
}
|
||||
// Linux: ~/.config/webzilla
|
||||
else {
|
||||
} else {
|
||||
return path.join(homedir, '.config', 'webzilla');
|
||||
}
|
||||
}
|
||||
|
||||
// 앱 시작 시 설정 디렉토리 생성
|
||||
const configDir = getConfigDir();
|
||||
if (!fs.existsSync(configDir)) {
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
console.log(`📂 설정 폴더가 생성되었습니다: ${configDir}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ 설정 폴더 생성 실패: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`📂 설정 폴더 로드됨: ${configDir}`);
|
||||
try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) { }
|
||||
}
|
||||
|
||||
const wss = new WebSocket.Server({ port: 8090 });
|
||||
const PORT = 8090;
|
||||
|
||||
console.log("🚀 WebZilla FTP Proxy Server가 ws://localhost:8090 에서 실행 중입니다.");
|
||||
// --- 서버 시작 함수 (재시도 로직 포함) ---
|
||||
function startServer() {
|
||||
const wss = new WebSocket.Server({ port: PORT });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log("클라이언트가 접속했습니다.");
|
||||
|
||||
const client = new ftp.Client();
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
switch (data.command) {
|
||||
// --- FTP 연결 관련 ---
|
||||
case 'CONNECT':
|
||||
console.log(`FTP 연결 시도: ${data.user}@${data.host}:${data.port}`);
|
||||
try {
|
||||
await client.access({
|
||||
host: data.host,
|
||||
user: data.user,
|
||||
password: data.pass,
|
||||
port: parseInt(data.port),
|
||||
secure: false
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP 서버에 연결되었습니다.' }));
|
||||
console.log("FTP 연결 성공");
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `연결 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'LIST':
|
||||
if (client.closed) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
||||
return;
|
||||
}
|
||||
const listPath = data.path || '/';
|
||||
const list = await client.list(listPath);
|
||||
|
||||
const files = list.map(f => ({
|
||||
id: `ftp-${Date.now()}-${Math.random()}`,
|
||||
name: f.name,
|
||||
type: f.isDirectory ? 'FOLDER' : 'FILE',
|
||||
size: f.size,
|
||||
date: f.rawModifiedAt || new Date().toISOString(),
|
||||
permissions: '-'
|
||||
}));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'list', files, path: listPath }));
|
||||
break;
|
||||
|
||||
case 'DISCONNECT':
|
||||
client.close();
|
||||
ws.send(JSON.stringify({ type: 'status', status: 'disconnected', message: '연결이 종료되었습니다.' }));
|
||||
break;
|
||||
|
||||
// --- 로컬 설정 저장 관련 (새로 추가됨) ---
|
||||
case 'SAVE_SITE':
|
||||
// 예: 사이트 정보를 JSON 파일로 저장
|
||||
try {
|
||||
const sitesFile = path.join(configDir, 'sites.json');
|
||||
let sites = [];
|
||||
if (fs.existsSync(sitesFile)) {
|
||||
sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8'));
|
||||
}
|
||||
sites.push(data.siteInfo);
|
||||
fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2));
|
||||
|
||||
console.log(`💾 사이트 정보 저장됨: ${data.siteInfo.host}`);
|
||||
ws.send(JSON.stringify({ type: 'success', message: '사이트 정보가 로컬(AppData)에 저장되었습니다.' }));
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `저장 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'GET_SITES':
|
||||
try {
|
||||
const sitesFile = path.join(configDir, 'sites.json');
|
||||
if (fs.existsSync(sitesFile)) {
|
||||
const sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8'));
|
||||
ws.send(JSON.stringify({ type: 'sites_list', sites }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'sites_list', sites: [] }));
|
||||
}
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `로드 실패: ${err.message}` }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`알 수 없는 명령: ${data.command}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("오류 발생:", err);
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
wss.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
|
||||
handlePortConflict();
|
||||
} else {
|
||||
console.error("❌ 서버 오류:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log("클라이언트 접속 종료");
|
||||
client.close();
|
||||
wss.on('listening', () => {
|
||||
console.log(`\n🚀 WebZilla FTP Proxy Server가 ws://localhost:${PORT} 에서 실행 중입니다.`);
|
||||
console.log(`📂 설정 폴더: ${configDir}`);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 실행
|
||||
startServer();
|
||||
@@ -9,15 +9,31 @@ interface CreateFolderModalProps {
|
||||
}
|
||||
|
||||
export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, onClose, onConfirm }) => {
|
||||
const [folderName, setFolderName] = useState('새 폴더');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFolderName('새 폴더');
|
||||
// Auto focus hack
|
||||
setTimeout(() => document.getElementById('new-folder-input')?.focus(), 50);
|
||||
setFolderName('');
|
||||
setError('');
|
||||
// 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;
|
||||
|
||||
@@ -33,18 +49,19 @@ export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, on
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">새 디렉토리 이름:</label>
|
||||
<input
|
||||
<input
|
||||
id="new-folder-input"
|
||||
type="text"
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm(folderName)}
|
||||
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
|
||||
onChange={(e) => { setFolderName(e.target.value); setError(''); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
|
||||
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 className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
<button onClick={() => onConfirm(folderName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20">확인</button>
|
||||
<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>
|
||||
@@ -62,13 +79,33 @@ interface RenameModalProps {
|
||||
|
||||
export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, onClose, onConfirm }) => {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setNewName(currentName);
|
||||
setTimeout(() => document.getElementById('rename-input')?.focus(), 50);
|
||||
setNewName(currentName);
|
||||
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;
|
||||
|
||||
@@ -85,20 +122,21 @@ export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, o
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">현재 이름:</label>
|
||||
<div className="text-sm text-slate-600 bg-slate-50 p-2 rounded border border-slate-200 mb-3 select-all">{currentName}</div>
|
||||
|
||||
|
||||
<label className="block text-xs text-slate-500 mb-1">새 이름:</label>
|
||||
<input
|
||||
<input
|
||||
id="rename-input"
|
||||
type="text"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm(newName)}
|
||||
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
|
||||
onChange={(e) => { setNewName(e.target.value); setError(''); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
|
||||
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 className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
<button onClick={() => onConfirm(newName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20">확인</button>
|
||||
<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>
|
||||
@@ -116,6 +154,16 @@ interface DeleteModalProps {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -129,18 +177,18 @@ export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fil
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="bg-red-50 p-2 rounded h-fit shrink-0">
|
||||
<AlertTriangle size={24} className="text-red-500" />
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 min-w-0">
|
||||
<p className="mb-2">정말로 다음 {fileCount}개 항목을 삭제하시겠습니까?</p>
|
||||
<ul className="list-disc list-inside text-xs text-slate-500 max-h-32 overflow-y-auto bg-slate-50 p-2 rounded border border-slate-200 mb-2">
|
||||
{fileNames.map((name, i) => (
|
||||
<li key={i} className="truncate">{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-red-500 font-semibold">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-2 rounded h-fit shrink-0">
|
||||
<AlertTriangle size={24} className="text-red-500" />
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 min-w-0">
|
||||
<p className="mb-2">정말로 다음 {fileCount}개 항목을 삭제하시겠습니까?</p>
|
||||
<ul className="list-disc list-inside text-xs text-slate-500 max-h-32 overflow-y-auto bg-slate-50 p-2 rounded border border-slate-200 mb-2">
|
||||
{fileNames.map((name, i) => (
|
||||
<li key={i} className="truncate">{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-red-500 font-semibold">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded">취소</button>
|
||||
|
||||
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;
|
||||
@@ -10,12 +10,12 @@ interface SiteManagerModalProps {
|
||||
initialSites: SiteConfig[];
|
||||
}
|
||||
|
||||
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnect,
|
||||
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveSites,
|
||||
initialSites
|
||||
initialSites
|
||||
}) => {
|
||||
const [sites, setSites] = useState<SiteConfig[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -60,11 +60,11 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
const handleDelete = () => {
|
||||
if (!selectedId) return;
|
||||
if (!window.confirm('선택한 사이트를 삭제하시겠습니까?')) return;
|
||||
|
||||
|
||||
const newSites = sites.filter(s => s.id !== selectedId);
|
||||
setSites(newSites);
|
||||
onSaveSites(newSites); // Auto save on delete
|
||||
|
||||
|
||||
if (newSites.length > 0) {
|
||||
selectSite(newSites[0]);
|
||||
} else {
|
||||
@@ -75,7 +75,7 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData) return;
|
||||
|
||||
|
||||
const newSites = sites.map(s => s.id === formData.id ? formData : s);
|
||||
setSites(newSites);
|
||||
onSaveSites(newSites);
|
||||
@@ -94,19 +94,19 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
const updateForm = (field: keyof SiteConfig, value: any) => {
|
||||
if (!formData) return;
|
||||
const updated = { ...formData, [field]: value };
|
||||
|
||||
|
||||
// Auto port update based on protocol
|
||||
if (field === 'protocol') {
|
||||
if (value === 'sftp') updated.port = '22';
|
||||
if (value === 'ftp') updated.port = '21';
|
||||
if (value === 'sftp') updated.port = '22';
|
||||
if (value === 'ftp') updated.port = '21';
|
||||
}
|
||||
|
||||
setFormData(updated);
|
||||
|
||||
|
||||
if (field === 'name') {
|
||||
setSites(sites.map(s => s.id === updated.id ? updated : s));
|
||||
}
|
||||
|
||||
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
|
||||
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-3xl flex flex-col h-[600px] max-h-[90vh]">
|
||||
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-slate-200 bg-slate-50 rounded-t-lg">
|
||||
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
@@ -131,13 +131,13 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
{/* Left: Site List */}
|
||||
<div className="w-1/3 border-r border-slate-200 flex flex-col bg-slate-50">
|
||||
<div className="p-2 border-b border-slate-200 flex gap-2">
|
||||
<button
|
||||
<button
|
||||
onClick={handleNewSite}
|
||||
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 text-xs py-1.5 rounded border border-slate-300 flex items-center justify-center gap-1 transition-colors shadow-sm"
|
||||
>
|
||||
<FolderPlus size={14} /> 새 사이트
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedId}
|
||||
className="bg-white hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 text-xs px-2 py-1.5 rounded border border-slate-300 transition-colors disabled:opacity-50 shadow-sm"
|
||||
@@ -145,17 +145,16 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{sites.map(site => (
|
||||
<div
|
||||
<div
|
||||
key={site.id}
|
||||
onClick={() => selectSite(site)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${
|
||||
selectedId === site.id
|
||||
? 'bg-blue-100 text-blue-900 border border-blue-200'
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${selectedId === site.id
|
||||
? 'bg-blue-100 text-blue-900 border border-blue-200'
|
||||
: 'text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Server size={14} className={selectedId === site.id ? 'text-blue-600' : 'text-slate-400'} />
|
||||
<span className="truncate">{site.name}</span>
|
||||
@@ -175,128 +174,137 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 px-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
일반 (General)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transfer')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'transfer' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
전송 설정 (Transfer)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${activeTab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
일반 (General)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transfer')}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${activeTab === 'transfer' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
|
||||
}`}
|
||||
>
|
||||
전송 설정 (Transfer)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 overflow-y-auto">
|
||||
{activeTab === 'general' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">사이트 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
{activeTab === 'general' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">사이트 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200 my-4" />
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">프로토콜</label>
|
||||
<select
|
||||
value={formData.protocol}
|
||||
onChange={(e) => updateForm('protocol', e.target.value as any)}
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
>
|
||||
<option value="ftp">FTP - 파일 전송 프로토콜</option>
|
||||
<option value="sftp">SFTP - SSH 파일 전송 프로토콜</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">호스트</label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.host}
|
||||
onChange={(e) => updateForm('host', e.target.value)}
|
||||
placeholder="ftp.example.com"
|
||||
className="flex-1 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">포트:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.port}
|
||||
onChange={(e) => updateForm('port', e.target.value)}
|
||||
className="w-16 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm text-center focus:border-blue-500 focus:outline-none text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.pass || ''}
|
||||
onChange={(e) => updateForm('pass', e.target.value)}
|
||||
placeholder="저장하지 않으려면 비워두세요"
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">호스트</label>
|
||||
<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="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">시작 디렉토리</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.initialPath || ''}
|
||||
onChange={(e) => updateForm('initialPath', e.target.value)}
|
||||
placeholder="/ (기본값)"
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">전송 모드</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${formData.passiveMode !== false ? 'bg-blue-600 border-blue-500' : 'bg-white border-slate-400'}`}>
|
||||
{formData.passiveMode !== false && <div className="w-2 h-2 bg-white rounded-sm" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.passiveMode !== false}
|
||||
onChange={(e) => updateForm('passiveMode', e.target.checked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<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 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 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"
|
||||
/>
|
||||
<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 className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500 text-right">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.pass || ''}
|
||||
onChange={(e) => updateForm('pass', e.target.value)}
|
||||
placeholder="저장하지 않으려면 비워두세요"
|
||||
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">전송 모드</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${formData.passiveMode !== false ? 'bg-blue-600 border-blue-500' : 'bg-white border-slate-400'}`}>
|
||||
{formData.passiveMode !== false && <div className="w-2 h-2 bg-white rounded-sm" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.passiveMode !== false}
|
||||
onChange={(e) => updateForm('passiveMode', e.target.checked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors">패시브 모드 (Passive Mode) 사용</div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 ml-6">
|
||||
서버가 방화벽/NAT 뒤에 있는 경우 패시브 모드를 사용하는 것이 좋습니다.
|
||||
(FTP 프로토콜에만 적용됩니다)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4">제한 설정</h3>
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<label className="text-xs text-slate-500">최대 연결 수:</label>
|
||||
<input type="number" defaultValue={2} disabled className="bg-slate-100 border border-slate-300 rounded px-2 py-1 text-sm text-slate-500" />
|
||||
<span className="text-xs text-slate-400 col-span-2">(데모 제한)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -311,26 +319,25 @@ const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-3 border-t border-slate-200 bg-slate-50 flex justify-between items-center rounded-b-lg">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors"
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData}
|
||||
className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${
|
||||
isDirty
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20'
|
||||
className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${isDirty
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20'
|
||||
: 'bg-white border border-slate-300 text-slate-500'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Save size={16} /> 저장
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={handleConnectClick}
|
||||
disabled={!formData}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-2 shadow-md shadow-blue-500/20"
|
||||
|
||||
1
types.ts
1
types.ts
@@ -44,4 +44,5 @@ export interface SiteConfig {
|
||||
user: string;
|
||||
pass?: string; // Optional for security
|
||||
passiveMode?: boolean;
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user