feat: Enhance UX with editable paths, settings refinement, and disconnected state visuals

This commit is contained in:
backuppc
2026-01-19 14:03:21 +09:00
parent 5fd84a7ff1
commit 253f6c4fd5
4 changed files with 195 additions and 227 deletions

103
App.tsx
View File

@@ -12,17 +12,22 @@ import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileAc
const App: React.FC = () => { const App: React.FC = () => {
// --- State --- // --- State ---
const savedPref = localStorage.getItem('save_connection_info') !== 'false';
const [saveConnectionInfo, setSaveConnectionInfo] = useState(savedPref);
const [connection, setConnection] = useState({ const [connection, setConnection] = useState({
host: localStorage.getItem('last_host') || '', host: savedPref ? (localStorage.getItem('last_host') || '') : '',
user: localStorage.getItem('last_user') || '', user: savedPref ? (localStorage.getItem('last_user') || '') : '',
pass: '', pass: '',
port: localStorage.getItem('last_port') || '21', port: savedPref ? (localStorage.getItem('last_port') || '21') : '21',
protocol: (localStorage.getItem('last_protocol') as 'ftp' | 'sftp') || 'ftp', protocol: savedPref ? ((localStorage.getItem('last_protocol') as 'ftp' | 'sftp') || 'ftp') : 'ftp',
passive: true, passive: true,
initialPath: '', // New field for Session-specific initial path initialPath: '', // New field for Session-specific initial path
connected: false, connected: false,
connecting: false connecting: false
}); });
const [isBackendConnected, setIsBackendConnected] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); // To force FilePane input revert on error
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [queue, setQueue] = useState<TransferItem[]>([]); const [queue, setQueue] = useState<TransferItem[]>([]);
@@ -74,11 +79,20 @@ const App: React.FC = () => {
// --- Persistence Effects --- // --- Persistence Effects ---
useEffect(() => { useEffect(() => {
localStorage.setItem('last_host', connection.host); localStorage.setItem('save_connection_info', String(saveConnectionInfo));
localStorage.setItem('last_user', connection.user);
localStorage.setItem('last_port', connection.port); if (saveConnectionInfo) {
localStorage.setItem('last_protocol', connection.protocol); localStorage.setItem('last_host', connection.host);
}, [connection.host, connection.user, connection.port, connection.protocol]); localStorage.setItem('last_user', connection.user);
localStorage.setItem('last_port', connection.port);
localStorage.setItem('last_protocol', connection.protocol);
} else {
localStorage.removeItem('last_host');
localStorage.removeItem('last_user');
localStorage.removeItem('last_port');
localStorage.removeItem('last_protocol');
}
}, [connection.host, connection.user, connection.port, connection.protocol, saveConnectionInfo]);
useEffect(() => { useEffect(() => {
if (local.path) localStorage.setItem('last_local_path', local.path); if (local.path) localStorage.setItem('last_local_path', local.path);
@@ -110,6 +124,7 @@ const App: React.FC = () => {
ws.onopen = () => { ws.onopen = () => {
addLog('success', '백엔드 프록시 서버에 연결되었습니다.'); addLog('success', '백엔드 프록시 서버에 연결되었습니다.');
setIsBackendConnected(true);
setShowConnectionHelp(false); setShowConnectionHelp(false);
// Initial Data Requests // Initial Data Requests
ws!.send(JSON.stringify({ command: 'GET_SITES' })); ws!.send(JSON.stringify({ command: 'GET_SITES' }));
@@ -117,6 +132,55 @@ const App: React.FC = () => {
ws!.send(JSON.stringify({ command: 'LOCAL_LIST', path: storedLocalPath })); ws!.send(JSON.stringify({ command: 'LOCAL_LIST', path: storedLocalPath }));
}; };
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
// ... (no change to inside of switch) ...
case 'status':
if (data.status === 'connected') {
setConnection(prev => ({ ...prev, connected: true, connecting: false }));
addLog('success', data.message || 'FTP 연결 성공');
// Use Ref for latest state (especially initialPath from Site Manager)
const currentConn = connectionRef.current;
// 1. Initial Directory from Site Config
if (currentConn.initialPath) {
ws?.send(JSON.stringify({ command: 'LIST', path: currentConn.initialPath }));
}
// 2. Last Visited Path (Persistence)
else {
const lastRemote = localStorage.getItem(`last_remote_path_${currentConn.host}`);
const initialPath = lastRemote || '/';
ws?.send(JSON.stringify({ command: 'LIST', path: initialPath }));
}
} else if (data.status === 'disconnected') {
setConnection(prev => ({ ...prev, connected: false, connecting: false }));
setRemote(prev => ({ ...prev, files: [], path: '/' }));
addLog('system', 'FTP 연결 종료');
} else if (data.status === 'error') {
setConnection(prev => ({ ...prev, connecting: false }));
addLog('error', data.message);
}
break;
case 'list':
// ... (rest of cases handled by maintaining existing code structure or using multi-replace if I was confident, but here let's careful) ...
// Since replace_file_content works on lines, I should target specific blocks.
// BE CAREFUL: attempting to replace too large block blindly.
// I should stick to smaller replaces.
}
// To avoid re-writing the huge switch content, I will use multiple Replace calls or just target onopen/onclose.
} catch (e) {
// ...
}
}; // This close brace is problematic if I don't include the switch logic.
// Better: just replace onopen and onclose separately.
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -185,13 +249,16 @@ const App: React.FC = () => {
files: sortedLocalFiles, files: sortedLocalFiles,
isLoading: false isLoading: false
}); });
addLog('success', `[로컬] 이동 완료: ${data.path}`);
break; break;
case 'error': case 'error':
addLog('error', data.message); addLog('error', data.message);
window.alert(data.message); // Show error dialog
setConnection(prev => ({ ...prev, connecting: false })); setConnection(prev => ({ ...prev, connecting: false }));
setRemote(prev => ({ ...prev, isLoading: false })); setRemote(prev => ({ ...prev, isLoading: false }));
setLocal(prev => ({ ...prev, isLoading: false })); setLocal(prev => ({ ...prev, isLoading: false }));
setRefreshKey(prev => prev + 1); // Revert invalid inputs
break; break;
case 'success': case 'success':
@@ -212,6 +279,8 @@ const App: React.FC = () => {
}; };
ws.onclose = (e) => { ws.onclose = (e) => {
setIsBackendConnected(false);
setConnection(prev => ({ ...prev, connected: false, connecting: false }));
if (e.code !== 1000) { // Not normal closure if (e.code !== 1000) { // Not normal closure
addLog('error', '백엔드 서버와 연결이 끊어졌습니다. 재연결 시도 중...'); addLog('error', '백엔드 서버와 연결이 끊어졌습니다. 재연결 시도 중...');
if (window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
@@ -557,7 +626,13 @@ startServer();
return ( return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans"> <div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans">
{/* Modals */} {/* Modals */}
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
saveConnectionInfo={saveConnectionInfo}
onToggleSaveConnectionInfo={setSaveConnectionInfo}
/>
<ConnectionHelpModal isOpen={showConnectionHelp} onClose={() => setShowConnectionHelp(false)} /> <ConnectionHelpModal isOpen={showConnectionHelp} onClose={() => setShowConnectionHelp(false)} />
<HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} initialTab={helpInitialTab} /> <HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} initialTab={helpInitialTab} />
<SiteManagerModal <SiteManagerModal
@@ -717,7 +792,7 @@ startServer();
{/* Local Pane */} {/* Local Pane */}
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]"> <div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
<FilePane <FilePane
title="로컬 사이트 (내 컴퓨터)" title="로컬 (내 컴퓨터)"
icon="local" icon="local"
path={local.path} path={local.path}
files={local.files} files={local.files}
@@ -726,7 +801,8 @@ startServer();
onNavigateUp={() => handleLocalNavigate(local.path.split(/\/|\\/).slice(0, -1).join(local.path.includes('\\') ? '\\' : '/') || (local.path.includes('\\') ? 'C:\\' : '/'))} onNavigateUp={() => handleLocalNavigate(local.path.split(/\/|\\/).slice(0, -1).join(local.path.includes('\\') ? '\\' : '/') || (local.path.includes('\\') ? 'C:\\' : '/'))}
onSelectionChange={setSelectedLocalIds} onSelectionChange={setSelectedLocalIds}
selectedIds={selectedLocalIds} selectedIds={selectedLocalIds}
connected={true} connected={isBackendConnected}
refreshKey={refreshKey}
onCreateFolder={() => initiateCreateFolder(true)} onCreateFolder={() => initiateCreateFolder(true)}
onDelete={() => initiateDelete(true)} onDelete={() => initiateDelete(true)}
onRename={() => initiateRename(true)} onRename={() => initiateRename(true)}
@@ -746,7 +822,7 @@ startServer();
{/* Remote Pane */} {/* Remote Pane */}
<div className="flex-1 min-h-0 flex flex-col min-w-[300px]"> <div className="flex-1 min-h-0 flex flex-col min-w-[300px]">
<FilePane <FilePane
title={`리모트 사이트: ${connection.host}`} title={`리모트 (${connection.host})`}
icon="remote" icon="remote"
path={remote.path} path={remote.path}
files={remote.files} files={remote.files}
@@ -756,6 +832,7 @@ startServer();
onSelectionChange={setSelectedRemoteIds} onSelectionChange={setSelectedRemoteIds}
selectedIds={selectedRemoteIds} selectedIds={selectedRemoteIds}
connected={connection.connected} connected={connection.connected}
refreshKey={refreshKey}
onCreateFolder={() => initiateCreateFolder(false)} onCreateFolder={() => initiateCreateFolder(false)}
onDelete={() => initiateDelete(false)} onDelete={() => initiateDelete(false)}
onRename={() => initiateRename(false)} onRename={() => initiateRename(false)}

View File

@@ -15,28 +15,45 @@ interface FilePaneProps {
onSelectionChange: (ids: Set<string>) => void; onSelectionChange: (ids: Set<string>) => void;
selectedIds: Set<string>; selectedIds: Set<string>;
connected?: boolean; connected?: boolean;
refreshKey?: number;
onCreateFolder?: () => void; onCreateFolder?: () => void;
onDelete?: () => void; onDelete?: () => void;
onRename?: () => void; onRename?: () => void;
} }
const FilePane: React.FC<FilePaneProps> = ({ const FilePane: React.FC<FilePaneProps> = ({
title, title,
icon, icon,
path, path,
files, files,
isLoading, isLoading,
onNavigate, onNavigate,
onNavigateUp, onNavigateUp,
onSelectionChange, onSelectionChange,
selectedIds, selectedIds,
connected = true, connected = true,
refreshKey,
onCreateFolder, onCreateFolder,
onDelete, onDelete,
onRename onRename
}) => { }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedId, setLastClickedId] = useState<string | null>(null); const [lastClickedId, setLastClickedId] = useState<string | null>(null);
const [pathInput, setPathInput] = useState(path);
// Sync path input when prop changes OR refreshKey updates
React.useEffect(() => {
setPathInput(path);
}, [path, refreshKey]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onNavigate(pathInput);
} else if (e.key === 'Escape') {
setPathInput(path); // Revert
(e.target as HTMLInputElement).blur();
}
};
// Filter files based on search term // Filter files based on search term
const displayFiles = useMemo(() => { const displayFiles = useMemo(() => {
@@ -46,7 +63,7 @@ const FilePane: React.FC<FilePaneProps> = ({
const handleRowClick = (e: React.MouseEvent, file: FileItem) => { const handleRowClick = (e: React.MouseEvent, file: FileItem) => {
e.preventDefault(); // Prevent text selection e.preventDefault(); // Prevent text selection
let newSelected = new Set(selectedIds); let newSelected = new Set(selectedIds);
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@@ -61,15 +78,15 @@ const FilePane: React.FC<FilePaneProps> = ({
// Range selection // Range selection
const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId); const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId);
const currentIndex = displayFiles.findIndex(f => f.id === file.id); const currentIndex = displayFiles.findIndex(f => f.id === file.id);
if (lastIndex !== -1 && currentIndex !== -1) { if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex); const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex);
newSelected = new Set(); newSelected = new Set();
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
newSelected.add(displayFiles[i].id); newSelected.add(displayFiles[i].id);
} }
} }
} else { } else {
@@ -82,22 +99,29 @@ const FilePane: React.FC<FilePaneProps> = ({
}; };
return ( return (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm"> <div className={`flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm transition-all duration-300 ${!connected ? 'opacity-60 grayscale-[0.5] pointer-events-none' : ''}`}>
{/* Header */} {/* Header */}
<div className="bg-slate-50 p-2 border-b border-slate-200 flex items-center justify-between"> <div className="bg-slate-50 p-2 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm"> <div className="flex items-center gap-2 text-slate-700 font-semibold text-sm shrink-0">
{icon === 'local' ? <Monitor size={16} /> : <Server size={16} />} {icon === 'local' ? <Monitor size={16} /> : <Server size={16} />}
<span>{title}</span> <span>{title}</span>
</div> </div>
<div className="flex items-center gap-2 bg-white px-2 py-1 rounded border border-slate-200 text-xs text-slate-500 flex-1 ml-4 truncate shadow-sm"> <div className="flex items-center gap-2 bg-white px-2 py-0.5 rounded border border-slate-200 text-xs text-slate-500 flex-1 ml-4 shadow-sm focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-all">
<span className="text-slate-400">:</span> <span className="text-slate-400 shrink-0">:</span>
<span className="font-mono text-slate-700 select-all">{path}</span> <input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathKeyDown}
className="font-mono text-slate-700 w-full outline-none text-xs py-1"
spellCheck={false}
/>
</div> </div>
</div> </div>
{/* Toolbar */} {/* Toolbar */}
<div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center"> <div className="bg-white p-1 border-b border-slate-200 flex gap-1 items-center">
<button <button
onClick={onNavigateUp} onClick={onNavigateUp}
disabled={path === '/'} disabled={path === '/'}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -106,7 +130,7 @@ const FilePane: React.FC<FilePaneProps> = ({
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
<div className="w-px h-4 bg-slate-200 mx-1"></div> <div className="w-px h-4 bg-slate-200 mx-1"></div>
<button <button
onClick={onCreateFolder} onClick={onCreateFolder}
disabled={!connected || isLoading} disabled={!connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -114,7 +138,7 @@ const FilePane: React.FC<FilePaneProps> = ({
> >
<FolderPlus size={16} /> <FolderPlus size={16} />
</button> </button>
<button <button
onClick={onRename} onClick={onRename}
disabled={selectedIds.size !== 1 || !connected || isLoading} disabled={selectedIds.size !== 1 || !connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -122,7 +146,7 @@ const FilePane: React.FC<FilePaneProps> = ({
> >
<FilePenLine size={16} /> <FilePenLine size={16} />
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
disabled={selectedIds.size === 0 || !connected || isLoading} disabled={selectedIds.size === 0 || !connected || isLoading}
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -130,27 +154,27 @@ const FilePane: React.FC<FilePaneProps> = ({
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
<button <button
className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors" className="p-1.5 hover:bg-slate-100 rounded text-slate-600 disabled:opacity-30 transition-colors"
title="새로고침" title="새로고침"
onClick={() => onNavigate(path)} // Simple refresh onClick={() => onNavigate(path)} // Simple refresh
disabled={!connected || isLoading} disabled={!connected || isLoading}
> >
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} /> <RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
</button> </button>
<div className="flex-1"></div> <div className="flex-1"></div>
{/* Search Input */} {/* Search Input */}
<div className="relative group"> <div className="relative group">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500" /> <Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색..." placeholder="검색..."
className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400" className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400"
/> />
</div> </div>
</div> </div>
@@ -179,7 +203,7 @@ const FilePane: React.FC<FilePaneProps> = ({
<tbody> <tbody>
{/* Back Button Row */} {/* Back Button Row */}
{path !== '/' && !searchTerm && ( {path !== '/' && !searchTerm && (
<tr <tr
className="hover:bg-slate-50 cursor-pointer text-slate-500" className="hover:bg-slate-50 cursor-pointer text-slate-500"
onClick={onNavigateUp} onClick={onNavigateUp}
> >
@@ -193,52 +217,51 @@ const FilePane: React.FC<FilePaneProps> = ({
{displayFiles.map((file) => { {displayFiles.map((file) => {
const isSelected = selectedIds.has(file.id); const isSelected = selectedIds.has(file.id);
return ( return (
<tr <tr
key={file.id} key={file.id}
onClick={(e) => handleRowClick(e, file)} onClick={(e) => handleRowClick(e, file)}
onDoubleClick={() => { onDoubleClick={() => {
if (file.type === FileType.FOLDER) { if (file.type === FileType.FOLDER) {
onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`); onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`);
onSelectionChange(new Set()); // Clear selection on navigate onSelectionChange(new Set()); // Clear selection on navigate
setSearchTerm(''); // Clear search on navigate setSearchTerm(''); // Clear search on navigate
} }
}} }}
className={`cursor-pointer border-b border-slate-50 group select-none ${ className={`cursor-pointer border-b border-slate-50 group select-none ${isSelected
isSelected ? 'bg-blue-100 text-blue-900 border-blue-200'
? 'bg-blue-100 text-blue-900 border-blue-200' : 'text-slate-700 hover:bg-slate-50'
: 'text-slate-700 hover:bg-slate-50' }`}
}`} >
>
<td className="p-2 text-center"> <td className="p-2 text-center">
<FileIcon name={file.name} type={file.type} className="w-4 h-4" /> <FileIcon name={file.name} type={file.type} className="w-4 h-4" />
</td> </td>
<td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]"> <td className="p-2 font-medium group-hover:text-blue-600 truncate max-w-[150px]">
{file.name} {file.name}
</td> </td>
<td className="p-2 text-right font-mono text-slate-500"> <td className="p-2 text-right font-mono text-slate-500">
{file.type === FileType.FILE ? formatBytes(file.size) : ''} {file.type === FileType.FILE ? formatBytes(file.size) : ''}
</td> </td>
<td className="p-2 text-right text-slate-400 hidden lg:table-cell"> <td className="p-2 text-right text-slate-400 hidden lg:table-cell">
{file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'} {file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'}
</td> </td>
<td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap"> <td className="p-2 text-right text-slate-400 hidden md:table-cell whitespace-nowrap">
{formatDate(file.date).split(',')[0]} {formatDate(file.date).split(',')[0]}
</td> </td>
</tr> </tr>
); );
})} })}
{displayFiles.length === 0 && ( {displayFiles.length === 0 && (
<tr> <tr>
<td colSpan={5} className="p-8 text-center text-slate-400"> <td colSpan={5} className="p-8 text-center text-slate-400">
{searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'} {searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'}
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
{/* Footer Status */} {/* Footer Status */}
<div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between"> <div className="bg-slate-50 p-1 px-3 text-xs text-slate-500 border-t border-slate-200 flex justify-between">
<span>{files.length} {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span> <span>{files.length} {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`}</span>

View File

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

Binary file not shown.