initial commit

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

View File

@@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import { FolderPlus, FilePenLine, Trash2, X, AlertTriangle } from 'lucide-react';
// --- Create Folder Modal ---
interface CreateFolderModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (name: string) => void;
}
export const CreateFolderModal: React.FC<CreateFolderModalProps> = ({ isOpen, onClose, onConfirm }) => {
const [folderName, setFolderName] = useState('새 폴더');
useEffect(() => {
if (isOpen) {
setFolderName('새 폴더');
// Auto focus hack
setTimeout(() => document.getElementById('new-folder-input')?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<FolderPlus size={16} className="text-blue-500" />
</h3>
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-xs text-slate-500 mb-1"> :</label>
<input
id="new-folder-input"
type="text"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onConfirm(folderName)}
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded"></button>
<button onClick={() => onConfirm(folderName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20"></button>
</div>
</div>
</div>
</div>
);
};
// --- Rename Modal ---
interface RenameModalProps {
isOpen: boolean;
currentName: string;
onClose: () => void;
onConfirm: (newName: string) => void;
}
export const RenameModal: React.FC<RenameModalProps> = ({ isOpen, currentName, onClose, onConfirm }) => {
const [newName, setNewName] = useState('');
useEffect(() => {
if (isOpen) {
setNewName(currentName);
setTimeout(() => document.getElementById('rename-input')?.focus(), 50);
}
}, [isOpen, currentName]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<FilePenLine size={16} className="text-yellow-500" /> /
</h3>
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-xs text-slate-500 mb-1"> :</label>
<div className="text-sm text-slate-600 bg-slate-50 p-2 rounded border border-slate-200 mb-3 select-all">{currentName}</div>
<label className="block text-xs text-slate-500 mb-1"> :</label>
<input
id="rename-input"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onConfirm(newName)}
className="w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm text-slate-800 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded"></button>
<button onClick={() => onConfirm(newName)} className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded shadow-md shadow-blue-500/20"></button>
</div>
</div>
</div>
</div>
);
};
// --- Delete Modal ---
interface DeleteModalProps {
isOpen: boolean;
fileCount: number;
fileNames: string[];
onClose: () => void;
onConfirm: () => void;
}
export const DeleteModal: React.FC<DeleteModalProps> = ({ isOpen, fileCount, fileNames, onClose, onConfirm }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-xl w-full max-w-sm animate-in fade-in zoom-in-95 duration-200">
<div className="p-3 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<Trash2 size={16} className="text-red-500" />
</h3>
<button onClick={onClose}><X size={16} className="text-slate-400 hover:text-slate-600" /></button>
</div>
<div className="p-4 space-y-4">
<div className="flex gap-3">
<div className="bg-red-50 p-2 rounded h-fit shrink-0">
<AlertTriangle size={24} className="text-red-500" />
</div>
<div className="text-sm text-slate-600 min-w-0">
<p className="mb-2"> {fileCount} ?</p>
<ul className="list-disc list-inside text-xs text-slate-500 max-h-32 overflow-y-auto bg-slate-50 p-2 rounded border border-slate-200 mb-2">
{fileNames.map((name, i) => (
<li key={i} className="truncate">{name}</li>
))}
</ul>
<p className="text-xs text-red-500 font-semibold"> .</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded"></button>
<button onClick={onConfirm} className="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-500 text-white rounded shadow-md shadow-red-500/20"></button>
</div>
</div>
</div>
</div>
);
};

251
components/FilePane.tsx Normal file
View File

@@ -0,0 +1,251 @@
import React, { useState, useMemo } from 'react';
import { FileItem, FileType } from '../types';
import { FileIcon } from './Icon';
import { formatBytes, formatDate } from '../utils/formatters';
import { ArrowUp, Server, Monitor, FolderOpen, RefreshCw, FolderPlus, Trash2, FilePenLine, Search } from 'lucide-react';
interface FilePaneProps {
title: string;
icon: 'local' | 'remote';
path: string;
files: FileItem[];
isLoading: boolean;
onNavigate: (path: string) => void;
onNavigateUp: () => void;
onSelectionChange: (ids: Set<string>) => void;
selectedIds: Set<string>;
connected?: boolean;
onCreateFolder?: () => void;
onDelete?: () => void;
onRename?: () => void;
}
const FilePane: React.FC<FilePaneProps> = ({
title,
icon,
path,
files,
isLoading,
onNavigate,
onNavigateUp,
onSelectionChange,
selectedIds,
connected = true,
onCreateFolder,
onDelete,
onRename
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedId, setLastClickedId] = useState<string | null>(null);
// Filter files based on search term
const displayFiles = useMemo(() => {
if (!searchTerm) return files;
return files.filter(f => f.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [files, searchTerm]);
const handleRowClick = (e: React.MouseEvent, file: FileItem) => {
e.preventDefault(); // Prevent text selection
let newSelected = new Set(selectedIds);
if (e.ctrlKey || e.metaKey) {
// Toggle selection
if (newSelected.has(file.id)) {
newSelected.delete(file.id);
} else {
newSelected.add(file.id);
}
setLastClickedId(file.id);
} else if (e.shiftKey && lastClickedId) {
// Range selection
const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId);
const currentIndex = displayFiles.findIndex(f => f.id === file.id);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
newSelected = new Set();
for (let i = start; i <= end; i++) {
newSelected.add(displayFiles[i].id);
}
}
} else {
// Single selection
newSelected = new Set([file.id]);
setLastClickedId(file.id);
}
onSelectionChange(newSelected);
};
return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
{/* Header */}
<div className="bg-slate-50 p-2 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm">
{icon === 'local' ? <Monitor size={16} /> : <Server size={16} />}
<span>{title}</span>
</div>
<div className="flex items-center gap-2 bg-white px-2 py-1 rounded border border-slate-200 text-xs text-slate-500 flex-1 ml-4 truncate shadow-sm">
<span className="text-slate-400">:</span>
<span className="font-mono text-slate-700 select-all">{path}</span>
</div>
</div>
{/* Toolbar */}
<div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center">
<button
onClick={onNavigateUp}
disabled={path === '/'}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="상위 폴더로 이동"
>
<ArrowUp size={16} />
</button>
<div className="w-px h-4 bg-slate-200 mx-1"></div>
<button
onClick={onCreateFolder}
disabled={!connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="새 폴더 생성"
>
<FolderPlus size={16} />
</button>
<button
onClick={onRename}
disabled={selectedIds.size !== 1 || !connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="이름 변경"
>
<FilePenLine size={16} />
</button>
<button
onClick={onDelete}
disabled={selectedIds.size === 0 || !connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="삭제"
>
<Trash2 size={16} />
</button>
<button
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors"
title="새로고침"
onClick={() => onNavigate(path)} // Simple refresh
disabled={!connected || isLoading}
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
</button>
<div className="flex-1"></div>
{/* Search Input */}
<div className="relative group">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색..."
className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400"
/>
</div>
</div>
{/* File Grid */}
<div className="flex-1 overflow-auto bg-white relative" onClick={() => onSelectionChange(new Set())}>
{!connected ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
<Server size={48} className="mb-4 opacity-20" />
<p> </p>
</div>
) : isLoading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
<p className="mt-4 text-sm"> ...</p>
</div>
) : (
<table className="w-full text-xs text-left border-collapse" onClick={(e) => e.stopPropagation()}>
<thead className="bg-slate-50 text-slate-500 sticky top-0 z-10 shadow-sm">
<tr>
<th className="p-2 w-8 font-medium border-b border-slate-200"></th>
<th className="p-2 font-medium border-b border-slate-200"></th>
<th className="p-2 w-24 font-medium border-b border-slate-200 text-right"></th>
<th className="p-2 w-32 font-medium border-b border-slate-200 text-right hidden lg:table-cell"></th>
<th className="p-2 w-32 font-medium border-b border-slate-200 text-right hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{/* Back Button Row */}
{path !== '/' && !searchTerm && (
<tr
className="hover:bg-slate-50 cursor-pointer text-slate-500"
onClick={onNavigateUp}
>
<td className="p-2 text-center"><FolderOpen size={14} /></td>
<td className="p-2 font-semibold">..</td>
<td className="p-2"></td>
<td className="p-2 hidden lg:table-cell"></td>
<td className="p-2 hidden md:table-cell"></td>
</tr>
)}
{displayFiles.map((file) => {
const isSelected = selectedIds.has(file.id);
return (
<tr
key={file.id}
onClick={(e) => handleRowClick(e, file)}
onDoubleClick={() => {
if (file.type === FileType.FOLDER) {
onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`);
onSelectionChange(new Set()); // Clear selection on navigate
setSearchTerm(''); // Clear search on navigate
}
}}
className={`cursor-pointer border-b border-slate-50 group select-none ${
isSelected
? 'bg-blue-100 text-blue-900 border-blue-200'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<td className="p-2 text-center">
<FileIcon name={file.name} type={file.type} className="w-4 h-4" />
</td>
<td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]">
{file.name}
</td>
<td className="p-2 text-right font-mono text-slate-500">
{file.type === FileType.FILE ? formatBytes(file.size) : ''}
</td>
<td className="p-2 text-right text-slate-400 hidden lg:table-cell">
{file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'}
</td>
<td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap">
{formatDate(file.date).split(',')[0]}
</td>
</tr>
);
})}
{displayFiles.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-400">
{searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Footer Status */}
<div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between">
<span>{files.length} {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span>
<span>{connected ? '준비됨' : '오프라인'}</span>
</div>
</div>
);
};
export default FilePane;

32
components/Icon.tsx Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import {
Folder,
FileText,
FileCode,
FileImage,
FileVideo,
File as FileGeneric,
Server,
Monitor,
ArrowUp,
ArrowDown,
CheckCircle,
XCircle,
Clock,
RefreshCw,
FolderOpen
} from 'lucide-react';
import { FileType } from '../types';
export const FileIcon: React.FC<{ name: string; type: FileType; className?: string }> = ({ name, type, className }) => {
if (type === FileType.FOLDER) return <Folder className={`text-blue-500 fill-blue-500/20 ${className}`} />;
const ext = name.split('.').pop()?.toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext || '')) return <FileImage className={`text-purple-600 ${className}`} />;
if (['mp4', 'mov', 'avi'].includes(ext || '')) return <FileVideo className={`text-pink-600 ${className}`} />;
if (['js', 'ts', 'tsx', 'html', 'css', 'json', 'py', 'php'].includes(ext || '')) return <FileCode className={`text-yellow-600 ${className}`} />;
if (['txt', 'md', 'log'].includes(ext || '')) return <FileText className={`text-slate-500 ${className}`} />;
return <FileGeneric className={`text-slate-400 ${className}`} />;
};

57
components/LogConsole.tsx Normal file
View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
import { LogEntry } from '../types';
interface LogConsoleProps {
logs: LogEntry[];
}
const LogConsole: React.FC<LogConsoleProps> = ({ logs }) => {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const getColor = (type: LogEntry['type']) => {
switch (type) {
case 'command': return 'text-blue-600';
case 'response': return 'text-green-600';
case 'error': return 'text-red-600';
case 'success': return 'text-emerald-600';
default: return 'text-slate-500';
}
};
return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden font-mono text-xs shadow-sm">
<div className="bg-slate-50 px-3 py-1 border-b border-slate-200 text-slate-500 text-xs font-semibold uppercase tracking-wider">
(Connection Log)
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-2 space-y-1 bg-white"
>
{logs.map((log) => (
<div key={log.id} className="flex gap-3 hover:bg-slate-50 p-0.5 rounded">
<span className="text-slate-400 shrink-0 select-none">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className={`${getColor(log.type)} break-all`}>
<span className="font-bold mr-2 uppercase text-[10px] opacity-70 border border-slate-200 px-1 rounded bg-slate-50 text-slate-600">
{log.type}
</span>
{log.message}
</span>
</div>
))}
{logs.length === 0 && (
<div className="text-slate-400 italic px-2"> . ...</div>
)}
</div>
</div>
);
};
export default LogConsole;

View File

@@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { X, Copy, Check, Server, Globe, ArrowLeftRight, HardDrive } from 'lucide-react';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'arch' | 'code'>('arch');
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const backendCodeDisplay = `/**
* WebZilla Backend Proxy (Node.js)
* Supports: FTP (basic-ftp) & SFTP (ssh2-sftp-client)
* Dependencies: npm install ws basic-ftp ssh2-sftp-client
*/
const WebSocket = require('ws');
const ftp = require('basic-ftp');
const SftpClient = require('ssh2-sftp-client');
// ... imports
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
let ftpClient = new ftp.Client();
let sftpClient = new SftpClient();
let currentProto = 'ftp';
ws.on('message', async (msg) => {
const data = JSON.parse(msg);
if (data.command === 'CONNECT') {
currentProto = data.protocol; // 'ftp' or 'sftp'
if (currentProto === 'sftp') {
await sftpClient.connect({
host: data.host,
port: data.port,
username: data.user,
password: data.pass
});
} else {
await ftpClient.access({
host: data.host,
user: data.user,
password: data.pass
});
}
ws.send(JSON.stringify({ status: 'connected' }));
}
// ... Handling LIST, MKD, DELE for both protocols
});
});`;
const handleCopy = () => {
navigator.clipboard.writeText(backendCodeDisplay);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-2xl flex flex-col max-h-[85vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Server size={20} className="text-blue-600" />
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200 px-4">
<button
onClick={() => setActiveTab('arch')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'arch' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setActiveTab('code')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'code' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
(Preview)
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1 text-slate-600">
{activeTab === 'arch' ? (
<div className="space-y-6">
<div className="bg-slate-50 p-6 rounded-lg border border-slate-200 flex flex-col md:flex-row items-center justify-between gap-4 text-center">
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center border border-blue-200">
<Globe size={32} className="text-blue-500" />
</div>
<span className="font-bold text-sm text-slate-700"></span>
<span className="text-xs text-slate-500">React Client</span>
</div>
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-green-600 bg-green-100 px-2 py-0.5 rounded border border-green-200 font-mono">WebSocket</span>
<ArrowLeftRight className="text-slate-400 w-full animate-pulse" />
<span className="text-xs text-slate-400">JSON Protocol</span>
</div>
<div className="flex flex-col items-center gap-2 relative">
<div className="w-16 h-16 bg-green-50 rounded-full flex items-center justify-center border border-green-200">
<Server size={32} className="text-green-500" />
</div>
<span className="font-bold text-sm text-slate-700">Node.js Proxy</span>
{/* AppData Connection */}
<div className="absolute -bottom-16 left-1/2 -translate-x-1/2 flex flex-col items-center">
<div className="h-6 w-px border-l border-dashed border-slate-300"></div>
<div className="bg-white border border-slate-300 px-2 py-1 rounded text-[10px] flex items-center gap-1 text-yellow-600 shadow-sm">
<HardDrive size={10} />
AppData/Config
</div>
</div>
</div>
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-orange-600 bg-orange-100 px-2 py-0.5 rounded border border-orange-200 font-mono">FTP / SFTP</span>
<ArrowLeftRight className="text-slate-400 w-full" />
</div>
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 bg-orange-50 rounded-full flex items-center justify-center border border-orange-200">
<Server size={32} className="text-orange-500" />
</div>
<span className="font-bold text-sm text-slate-700">Remote Server</span>
</div>
</div>
<div className="space-y-2">
<h3 className="font-bold text-slate-800"> (v1.1)</h3>
<ul className="list-disc list-inside text-sm text-slate-600 space-y-1 ml-2">
<li><span className="text-green-600 font-semibold">SFTP :</span> SSH2 .</li>
<li><span className="text-blue-600 font-semibold"> :</span> FTP UI .</li>
<li><span className="text-yellow-600 font-semibold"> :</span> , , .</li>
</ul>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
SFTP와 FTP를 .
</p>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium transition-colors shadow-sm"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '복사됨' : '코드 복사'}
</button>
</div>
<div className="relative group">
<pre className="bg-slate-800 p-4 rounded-lg overflow-x-auto text-xs font-mono text-slate-200 border border-slate-700 leading-relaxed shadow-inner">
{backendCodeDisplay}
</pre>
</div>
<p className="text-xs text-slate-400 italic text-center">
'백엔드 다운로드' .
</p>
</div>
)}
</div>
<div className="p-4 border-t border-slate-200 bg-slate-50 rounded-b-lg flex justify-end">
<button onClick={onClose} className="px-4 py-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-600 rounded text-sm transition-colors shadow-sm">
</button>
</div>
</div>
</div>
);
};
export default SettingsModal;

View File

@@ -0,0 +1,347 @@
import React, { useState, useEffect } from 'react';
import { X, Server, FolderPlus, Trash2, Save, Power, Monitor, Settings2 } from 'lucide-react';
import { SiteConfig } from '../types';
interface SiteManagerModalProps {
isOpen: boolean;
onClose: () => void;
onConnect: (site: SiteConfig) => void;
onSaveSites: (sites: SiteConfig[]) => void;
initialSites: SiteConfig[];
}
const SiteManagerModal: React.FC<SiteManagerModalProps> = ({
isOpen,
onClose,
onConnect,
onSaveSites,
initialSites
}) => {
const [sites, setSites] = useState<SiteConfig[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [formData, setFormData] = useState<SiteConfig | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'transfer'>('general');
// Initialize
useEffect(() => {
if (isOpen) {
setSites(initialSites);
if (initialSites.length > 0 && !selectedId) {
selectSite(initialSites[0]);
}
}
}, [isOpen, initialSites]);
const selectSite = (site: SiteConfig) => {
setSelectedId(site.id);
setFormData({ ...site });
setIsDirty(false);
setActiveTab('general');
};
const handleNewSite = () => {
const newSite: SiteConfig = {
id: `site-${Date.now()}`,
name: '새 사이트',
protocol: 'ftp',
host: '',
port: '21',
user: '',
pass: '',
passiveMode: true
};
const newSites = [...sites, newSite];
setSites(newSites);
selectSite(newSite);
setIsDirty(true);
};
const handleDelete = () => {
if (!selectedId) return;
if (!window.confirm('선택한 사이트를 삭제하시겠습니까?')) return;
const newSites = sites.filter(s => s.id !== selectedId);
setSites(newSites);
onSaveSites(newSites); // Auto save on delete
if (newSites.length > 0) {
selectSite(newSites[0]);
} else {
setSelectedId(null);
setFormData(null);
}
};
const handleSave = () => {
if (!formData) return;
const newSites = sites.map(s => s.id === formData.id ? formData : s);
setSites(newSites);
onSaveSites(newSites);
setIsDirty(false);
};
const handleConnectClick = () => {
if (formData) {
if (isDirty) {
handleSave();
}
onConnect(formData);
}
};
const updateForm = (field: keyof SiteConfig, value: any) => {
if (!formData) return;
const updated = { ...formData, [field]: value };
// Auto port update based on protocol
if (field === 'protocol') {
if (value === 'sftp') updated.port = '22';
if (value === 'ftp') updated.port = '21';
}
setFormData(updated);
if (field === 'name') {
setSites(sites.map(s => s.id === updated.id ? updated : s));
}
setIsDirty(true);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm p-4">
<div className="bg-white border border-slate-200 rounded-lg shadow-2xl w-full max-w-3xl flex flex-col h-[600px] max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-slate-200 bg-slate-50 rounded-t-lg">
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<Server size={18} className="text-blue-600" />
(Site Manager)
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
<X size={18} />
</button>
</div>
<div className="flex flex-1 min-h-0">
{/* Left: Site List */}
<div className="w-1/3 border-r border-slate-200 flex flex-col bg-slate-50">
<div className="p-2 border-b border-slate-200 flex gap-2">
<button
onClick={handleNewSite}
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 text-xs py-1.5 rounded border border-slate-300 flex items-center justify-center gap-1 transition-colors shadow-sm"
>
<FolderPlus size={14} />
</button>
<button
onClick={handleDelete}
disabled={!selectedId}
className="bg-white hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 text-xs px-2 py-1.5 rounded border border-slate-300 transition-colors disabled:opacity-50 shadow-sm"
>
<Trash2 size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{sites.map(site => (
<div
key={site.id}
onClick={() => selectSite(site)}
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer text-sm select-none transition-colors ${
selectedId === site.id
? 'bg-blue-100 text-blue-900 border border-blue-200'
: 'text-slate-600 hover:bg-slate-200'
}`}
>
<Server size={14} className={selectedId === site.id ? 'text-blue-600' : 'text-slate-400'} />
<span className="truncate">{site.name}</span>
</div>
))}
{sites.length === 0 && (
<div className="text-center text-slate-400 text-xs mt-10">
.
</div>
)}
</div>
</div>
{/* Right: Site Details Form */}
<div className="w-2/3 flex flex-col bg-white">
{formData ? (
<>
{/* Tabs */}
<div className="flex border-b border-slate-200 px-4">
<button
onClick={() => setActiveTab('general')}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
activeTab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
}`}
>
(General)
</button>
<button
onClick={() => setActiveTab('transfer')}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
activeTab === 'transfer' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500'
}`}
>
(Transfer)
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{activeTab === 'general' ? (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"> </label>
<input
type="text"
value={formData.name}
onChange={(e) => updateForm('name', e.target.value)}
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
/>
</div>
<hr className="border-slate-200 my-4" />
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"></label>
<select
value={formData.protocol}
onChange={(e) => updateForm('protocol', e.target.value as any)}
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
>
<option value="ftp">FTP - </option>
<option value="sftp">SFTP - SSH </option>
</select>
</div>
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"></label>
<div className="col-span-3 flex gap-2">
<input
type="text"
value={formData.host}
onChange={(e) => updateForm('host', e.target.value)}
placeholder="ftp.example.com"
className="flex-1 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
/>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">:</span>
<input
type="text"
value={formData.port}
onChange={(e) => updateForm('port', e.target.value)}
className="w-16 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm text-center focus:border-blue-500 focus:outline-none text-slate-800"
/>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"> (ID)</label>
<input
type="text"
value={formData.user}
onChange={(e) => updateForm('user', e.target.value)}
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800"
/>
</div>
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500 text-right"></label>
<input
type="password"
value={formData.pass || ''}
onChange={(e) => updateForm('pass', e.target.value)}
placeholder="저장하지 않으려면 비워두세요"
className="col-span-3 bg-white border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none text-slate-800 placeholder:text-slate-400"
/>
</div>
</div>
) : (
<div className="space-y-6">
<div className="space-y-3">
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4"> </h3>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer group">
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${formData.passiveMode !== false ? 'bg-blue-600 border-blue-500' : 'bg-white border-slate-400'}`}>
{formData.passiveMode !== false && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
<input
type="checkbox"
checked={formData.passiveMode !== false}
onChange={(e) => updateForm('passiveMode', e.target.checked)}
className="hidden"
/>
<div className="text-sm text-slate-700 group-hover:text-blue-600 transition-colors"> (Passive Mode) </div>
</label>
</div>
<p className="text-xs text-slate-500 ml-6">
/NAT .
(FTP )
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-bold text-slate-800 border-b border-slate-200 pb-2 mb-4"> </h3>
<div className="grid grid-cols-4 gap-4 items-center">
<label className="text-xs text-slate-500"> :</label>
<input type="number" defaultValue={2} disabled className="bg-slate-100 border border-slate-300 rounded px-2 py-1 text-sm text-slate-500" />
<span className="text-xs text-slate-400 col-span-2">( )</span>
</div>
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<Server size={48} className="mb-4 opacity-20" />
<p> </p>
<p>'새 사이트' .</p>
</div>
)}
</div>
</div>
{/* Footer Actions */}
<div className="p-3 border-t border-slate-200 bg-slate-50 flex justify-between items-center rounded-b-lg">
<button
onClick={onClose}
className="px-4 py-2 text-slate-500 hover:text-slate-800 text-sm transition-colors"
>
</button>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!formData}
className={`px-4 py-2 text-sm rounded flex items-center gap-2 transition-colors shadow-sm ${
isDirty
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/20'
: 'bg-white border border-slate-300 text-slate-500'
}`}
>
<Save size={16} />
</button>
<button
onClick={handleConnectClick}
disabled={!formData}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-2 shadow-md shadow-blue-500/20"
>
<Power size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default SiteManagerModal;

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useRef } from 'react';
import { TransferItem } from '../types';
import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock } from 'lucide-react';
interface TransferQueueProps {
queue: TransferItem[];
}
const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
<div className="bg-slate-50 px-3 py-2 border-b border-slate-200 flex justify-between items-center">
<span className="text-slate-600 text-xs font-semibold uppercase tracking-wider"> (Queue)</span>
<div className="text-xs text-slate-500">
{queue.filter(i => i.status === 'transferring').length}
</div>
</div>
<div className="flex-1 overflow-y-auto bg-white">
<table className="w-full text-xs text-left border-collapse">
<thead className="bg-slate-50 text-slate-500 sticky top-0 border-b border-slate-200">
<tr>
<th className="p-2 w-8"></th>
<th className="p-2"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-48"></th>
<th className="p-2 w-24 text-right"></th>
</tr>
</thead>
<tbody>
{queue.map((item) => (
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
<td className="p-2 text-center">
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
{item.status === 'failed' && <XCircle size={14} className="text-red-500" />}
{item.status === 'queued' && <Clock size={14} className="text-slate-400" />}
{item.status === 'transferring' && (
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
)}
</td>
<td className="p-2 truncate max-w-[200px] font-medium">{item.filename}</td>
<td className="p-2">
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
{item.direction === 'upload' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
{item.direction === 'upload' ? '업로드' : '다운로드'}
</span>
</td>
<td className="p-2">
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-200 ${
item.status === 'completed' ? 'bg-emerald-500' :
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
}`}
style={{ width: `${item.progress}%` }}
/>
</div>
</td>
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
</tr>
))}
{queue.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-400 italic">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default TransferQueue;