initial commit
This commit is contained in:
153
components/FileActionModals.tsx
Normal file
153
components/FileActionModals.tsx
Normal 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
251
components/FilePane.tsx
Normal 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
32
components/Icon.tsx
Normal 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
57
components/LogConsole.tsx
Normal 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;
|
||||
194
components/SettingsModal.tsx
Normal file
194
components/SettingsModal.tsx
Normal 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;
|
||||
347
components/SiteManagerModal.tsx
Normal file
347
components/SiteManagerModal.tsx
Normal 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;
|
||||
76
components/TransferQueue.tsx
Normal file
76
components/TransferQueue.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user