feat: Add Help System, Local File Operations, Site Manager improvements, and UI refinements
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user