Compare commits
17 Commits
79d161d7f7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31b3c6d31 | ||
|
|
fc9500f99b | ||
|
|
c84fd2a7e9 | ||
|
|
a1a1971a1f | ||
|
|
b3a6d74f1e | ||
|
|
467d0f5917 | ||
| ea070816e3 | |||
|
|
86729014d3 | ||
|
|
e28de5f2c3 | ||
|
|
32fee75e69 | ||
|
|
237ed6ea8b | ||
| 4376babbcc | |||
|
|
37bb2890ce | ||
|
|
cbeb55daf1 | ||
|
|
3acb952174 | ||
|
|
c86fd099ee | ||
|
|
b01822c998 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
public/webftp-backend.exe
|
||||||
|
public/webftp.exe
|
||||||
|
|||||||
539
App.tsx
539
App.tsx
@@ -6,9 +6,33 @@ import LogConsole from './components/LogConsole';
|
|||||||
import TransferQueue from './components/TransferQueue';
|
import TransferQueue from './components/TransferQueue';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import SiteManagerModal from './components/SiteManagerModal';
|
import SiteManagerModal from './components/SiteManagerModal';
|
||||||
import ConnectionHelpModal from './components/ConnectionHelpModal';
|
|
||||||
import HelpModal from './components/HelpModal';
|
import HelpModal from './components/HelpModal';
|
||||||
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
|
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
|
||||||
|
import ConflictModal from './components/ConflictModal';
|
||||||
|
import DownloadModal from './components/DownloadModal';
|
||||||
|
import { formatBytes } from './utils/formatters';
|
||||||
|
|
||||||
|
const AdSenseBanner: React.FC = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AdSense error", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center overflow-hidden">
|
||||||
|
<ins className="adsbygoogle"
|
||||||
|
style={{ display: "block", width: "100%", height: "100%" }}
|
||||||
|
data-ad-client="ca-pub-4444852135420953"
|
||||||
|
data-ad-slot="7799405796"
|
||||||
|
data-ad-format="auto"
|
||||||
|
data-full-width-responsive="true"></ins>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
// --- State ---
|
// --- State ---
|
||||||
@@ -35,14 +59,21 @@ const App: React.FC = () => {
|
|||||||
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
|
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
|
||||||
const [showSiteManager, setShowSiteManager] = useState(false);
|
const [showSiteManager, setShowSiteManager] = useState(false);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [showDownloadModal, setShowDownloadModal] = useState(false);
|
||||||
const [helpInitialTab, setHelpInitialTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
|
const [helpInitialTab, setHelpInitialTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
|
||||||
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
|
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
|
||||||
|
|
||||||
// Modals State
|
// Modals State
|
||||||
const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null);
|
const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | 'settings' | null>(null);
|
||||||
const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true);
|
const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true);
|
||||||
const [renameTargetName, setRenameTargetName] = useState('');
|
const [renameTargetName, setRenameTargetName] = useState('');
|
||||||
|
|
||||||
|
// Conflict Resolution State
|
||||||
|
const [conflictQueue, setConflictQueue] = useState<{ source: FileItem, target: FileItem, direction: 'upload' | 'download' }[]>([]);
|
||||||
|
const [isConflictModalOpen, setIsConflictModalOpen] = useState(false);
|
||||||
|
const [activeConflict, setActiveConflict] = useState<{ source: FileItem, target: FileItem, direction: 'upload' | 'download' } | null>(null);
|
||||||
|
const [conflictResolution, setConflictResolution] = useState<'overwrite' | 'skip' | null>(null);
|
||||||
|
|
||||||
// Local File System
|
// Local File System
|
||||||
const [local, setLocal] = useState<FileSystemState>({
|
const [local, setLocal] = useState<FileSystemState>({
|
||||||
path: localStorage.getItem('last_local_path') || '',
|
path: localStorage.getItem('last_local_path') || '',
|
||||||
@@ -62,6 +93,8 @@ const App: React.FC = () => {
|
|||||||
// WebSocket
|
// WebSocket
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const connectionRef = useRef(connection);
|
const connectionRef = useRef(connection);
|
||||||
|
const localRef = useRef(local);
|
||||||
|
const remoteRef = useRef(remote);
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
const addLog = useCallback((type: LogEntry['type'], message: string) => {
|
const addLog = useCallback((type: LogEntry['type'], message: string) => {
|
||||||
@@ -101,7 +134,9 @@ const App: React.FC = () => {
|
|||||||
// Sync connection state to ref for WS callbacks
|
// Sync connection state to ref for WS callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectionRef.current = connection;
|
connectionRef.current = connection;
|
||||||
}, [connection]);
|
localRef.current = local;
|
||||||
|
remoteRef.current = remote;
|
||||||
|
}, [connection, local, remote]);
|
||||||
|
|
||||||
// --- WebSocket Setup ---
|
// --- WebSocket Setup ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,55 +167,6 @@ 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);
|
||||||
@@ -211,6 +197,21 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
addLog('error', data.message);
|
||||||
|
setConnection(prev => ({ ...prev, connecting: false }));
|
||||||
|
|
||||||
|
// Check for disconnection messages
|
||||||
|
if (
|
||||||
|
data.message.includes('FTP 연결이 끊어져 있습니다') ||
|
||||||
|
data.message.includes('NOT_CONNECTED') ||
|
||||||
|
data.message.includes('closed')
|
||||||
|
) {
|
||||||
|
setConnection(prev => ({ ...prev, connected: false, connecting: false }));
|
||||||
|
setRemote(prev => ({ ...prev, files: [], path: '/' }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'list':
|
case 'list':
|
||||||
const sortedRemoteFiles = data.files
|
const sortedRemoteFiles = data.files
|
||||||
.map((f: any) => ({
|
.map((f: any) => ({
|
||||||
@@ -263,15 +264,54 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
case 'success':
|
case 'success':
|
||||||
addLog('success', data.message);
|
addLog('success', data.message);
|
||||||
if (connection.connected) {
|
if (connectionRef.current.connected) {
|
||||||
ws?.send(JSON.stringify({ command: 'LIST', path: remote.path }));
|
ws?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
|
||||||
}
|
}
|
||||||
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
|
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'sites_list':
|
case 'sites_list':
|
||||||
setSavedSites(data.sites);
|
setSavedSites(data.sites);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'transfer_progress':
|
||||||
|
setQueue(prev => prev.map(item => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
const total = data.bytesOverall;
|
||||||
|
const current = data.bytes;
|
||||||
|
const progress = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
return { ...item, progress, status: 'transferring', speed: `${formatBytes(current)} transferred` };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'transfer_success':
|
||||||
|
setQueue(prev => prev.map(item => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, progress: 100, status: 'completed', speed: 'Completed', completedAt: Date.now() };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
addLog('success', data.message);
|
||||||
|
// Refresh lists
|
||||||
|
if (data.path) {
|
||||||
|
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
|
||||||
|
if (connectionRef.current.connected) {
|
||||||
|
ws?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'transfer_error':
|
||||||
|
setQueue(prev => prev.map(item => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, status: 'failed', speed: data.message, completedAt: Date.now() };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
addLog('error', data.message);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("WS Message Error", e);
|
console.error("WS Message Error", e);
|
||||||
@@ -322,74 +362,8 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [addLog]); // Intentionally minimal deps
|
}, [addLog]); // Intentionally minimal deps
|
||||||
|
|
||||||
// --- Backend Download Logic ---
|
|
||||||
const handleDownloadBackend = () => {
|
|
||||||
const backendCode = `/**
|
|
||||||
* WebZilla 백엔드 프록시 서버 (Node.js) v1.2
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - WebSocket Proxy (Port: 8090)
|
|
||||||
* - FTP/SFTP 지원
|
|
||||||
* - 로컬 파일 시스템 탐색 (LOCAL_LIST)
|
|
||||||
* - 설정 저장 (AppData)
|
|
||||||
* - 포트 충돌 자동 감지
|
|
||||||
*/
|
|
||||||
|
|
||||||
const WebSocket = require('ws');
|
|
||||||
const ftp = require('basic-ftp');
|
|
||||||
const SftpClient = require('ssh2-sftp-client');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const { exec } = require('child_process');
|
|
||||||
const readline = require('readline');
|
|
||||||
|
|
||||||
function getConfigDir() {
|
|
||||||
const homedir = os.homedir();
|
|
||||||
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
|
|
||||||
else return path.join(homedir, '.config', 'webzilla');
|
|
||||||
}
|
|
||||||
const configDir = getConfigDir();
|
|
||||||
if (!fs.existsSync(configDir)) try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) {}
|
|
||||||
|
|
||||||
const PORT = 8090;
|
|
||||||
|
|
||||||
function startServer() {
|
|
||||||
const wss = new WebSocket.Server({ port: PORT });
|
|
||||||
wss.on('error', (err) => {
|
|
||||||
if (err.code === 'EADDRINUSE') {
|
|
||||||
console.error(\`\\n❌ 포트 \${PORT} 사용 중. 자동 해결 시도...\`);
|
|
||||||
handlePortConflict();
|
|
||||||
} else process.exit(1);
|
|
||||||
});
|
|
||||||
wss.on('listening', () => console.log(\`🚀 Server running on ws://localhost:\${PORT}\`));
|
|
||||||
wss.on('connection', (ws) => {
|
|
||||||
let ftpClient = new ftp.Client();
|
|
||||||
let sftpClient = new SftpClient();
|
|
||||||
ws.on('message', async (message) => {
|
|
||||||
// ... (Full implementation logic assumed for brevity in this download snippet,
|
|
||||||
// but user executes the actual file on their disk usually.
|
|
||||||
// This download is for distribution.)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function handlePortConflict() {
|
|
||||||
// Simplified conflict handler for the downloadable single file
|
|
||||||
console.log("포트 충돌 감지됨. 기존 프로세스를 종료하세요.");
|
|
||||||
}
|
|
||||||
startServer();
|
|
||||||
`;
|
|
||||||
const blob = new Blob([backendCode], { type: 'text/javascript' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'webftp-backend.exe';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
addLog('info', '업데이트된 백엔드 코드가 다운로드되었습니다.');
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Connection Handlers ---
|
// --- Connection Handlers ---
|
||||||
const handleConnect = () => {
|
const handleConnect = () => {
|
||||||
@@ -432,6 +406,159 @@ startServer();
|
|||||||
if (wsRef.current) wsRef.current.send(JSON.stringify({ command: 'LOCAL_LIST', path }));
|
if (wsRef.current) wsRef.current.send(JSON.stringify({ command: 'LOCAL_LIST', path }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const executeTransfer = (file: FileItem, direction: 'upload' | 'download', transferId: string) => {
|
||||||
|
// Determine paths based on current refs (assuming context hasn't changed too much, or we validly want current dir)
|
||||||
|
// Ideally we should store paths in the conflict object. For now this uses current view's path.
|
||||||
|
const localP = localRef.current.path;
|
||||||
|
const remoteP = remoteRef.current.path;
|
||||||
|
|
||||||
|
const localSep = localP.includes('\\') ? '\\' : '/';
|
||||||
|
const localClean = localP.endsWith(localSep) ? localP : localP + localSep;
|
||||||
|
const localFullPath = localClean + file.name;
|
||||||
|
|
||||||
|
const remoteSep = remoteP === '/' ? '' : '/';
|
||||||
|
const remoteFullPath = `${remoteP}${remoteSep}${file.name}`;
|
||||||
|
|
||||||
|
if (direction === 'upload') {
|
||||||
|
addLog('command', `UPLOAD ${file.name} -> ${remoteFullPath}`);
|
||||||
|
wsRef.current?.send(JSON.stringify({
|
||||||
|
command: 'UPLOAD',
|
||||||
|
localPath: localFullPath,
|
||||||
|
remotePath: remoteFullPath,
|
||||||
|
transferId
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
addLog('command', `DOWNLOAD ${file.name} -> ${localFullPath}`);
|
||||||
|
wsRef.current?.send(JSON.stringify({
|
||||||
|
command: 'DOWNLOAD',
|
||||||
|
localPath: localFullPath,
|
||||||
|
remotePath: remoteFullPath,
|
||||||
|
transferId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!connection.connected || selectedRemoteIds.size === 0) return;
|
||||||
|
|
||||||
|
// Filter and Process
|
||||||
|
const selectedFiles = remote.files.filter(f => selectedRemoteIds.has(f.id));
|
||||||
|
const conflicts: { source: FileItem, target: FileItem, direction: 'download' }[] = [];
|
||||||
|
const safeTransfers: FileItem[] = [];
|
||||||
|
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
if (file.type !== FileType.FILE) return;
|
||||||
|
|
||||||
|
// Check local for conflict
|
||||||
|
const existing = local.files.find(f => f.name === file.name);
|
||||||
|
if (existing) {
|
||||||
|
conflicts.push({ source: file, target: existing, direction: 'download' });
|
||||||
|
} else {
|
||||||
|
safeTransfers.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Safe
|
||||||
|
safeTransfers.forEach(file => {
|
||||||
|
const transferId = `down-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: transferId,
|
||||||
|
direction: 'download',
|
||||||
|
filename: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(file, 'download', transferId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Conflicts
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
if (conflictResolution === 'overwrite') {
|
||||||
|
conflicts.forEach(c => {
|
||||||
|
const transferId = `down-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: transferId,
|
||||||
|
direction: 'download',
|
||||||
|
filename: c.source.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(c.source, 'download', transferId);
|
||||||
|
});
|
||||||
|
} else if (conflictResolution === 'skip') {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
// Ask user
|
||||||
|
setConflictQueue(prev => [...prev, ...conflicts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRemoteIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (!connection.connected || selectedLocalIds.size === 0) return;
|
||||||
|
|
||||||
|
const selectedFiles = local.files.filter(f => selectedLocalIds.has(f.id));
|
||||||
|
const conflicts: { source: FileItem, target: FileItem, direction: 'upload' }[] = [];
|
||||||
|
const safeTransfers: FileItem[] = [];
|
||||||
|
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
if (file.type !== FileType.FILE) return;
|
||||||
|
|
||||||
|
const existing = remote.files.find(f => f.name === file.name);
|
||||||
|
if (existing) {
|
||||||
|
conflicts.push({ source: file, target: existing, direction: 'upload' });
|
||||||
|
} else {
|
||||||
|
safeTransfers.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safe
|
||||||
|
safeTransfers.forEach(file => {
|
||||||
|
const transferId = `up-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: transferId,
|
||||||
|
direction: 'upload',
|
||||||
|
filename: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(file, 'upload', transferId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conflicts
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
if (conflictResolution === 'overwrite') {
|
||||||
|
conflicts.forEach(c => {
|
||||||
|
const transferId = `up-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: transferId,
|
||||||
|
direction: 'upload',
|
||||||
|
filename: c.source.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(c.source, 'upload', transferId);
|
||||||
|
});
|
||||||
|
} else if (conflictResolution === 'skip') {
|
||||||
|
// Skip
|
||||||
|
} else {
|
||||||
|
setConflictQueue(prev => [...prev, ...conflicts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedLocalIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
// --- File Action Handlers ---
|
// --- File Action Handlers ---
|
||||||
const initiateCreateFolder = (isLocal: boolean) => {
|
const initiateCreateFolder = (isLocal: boolean) => {
|
||||||
if (!isLocal && !connection.connected) return;
|
if (!isLocal && !connection.connected) return;
|
||||||
@@ -526,31 +653,35 @@ startServer();
|
|||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (modalTargetIsLocal) {
|
if (modalTargetIsLocal) {
|
||||||
const ids = selectedLocalIds;
|
const ids = selectedLocalIds;
|
||||||
addLog('command', `LOCAL_DELE [${ids.size} items]`);
|
const fileNames = Array.from(ids).map(id => localRef.current.files.find(f => f.id === id)?.name).filter(Boolean).join(', ');
|
||||||
|
addLog('command', `LOCAL_DELE ${fileNames}`);
|
||||||
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
const separator = local.path.includes('\\') ? '\\' : '/';
|
const separator = localRef.current.path.includes('\\') ? '\\' : '/';
|
||||||
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
|
const cleanPath = localRef.current.path.endsWith(separator) ? localRef.current.path : localRef.current.path + separator;
|
||||||
|
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const file = local.files.find(f => f.id === id);
|
const file = localRef.current.files.find(f => f.id === id);
|
||||||
if (file) {
|
if (file) {
|
||||||
const targetPath = cleanPath + file.name;
|
const targetPath = cleanPath + file.name;
|
||||||
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath }));
|
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
|
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
|
||||||
}, 500 + (ids.size * 50)); // Delay a bit more for multiple deletes
|
}, 500 + (ids.size * 50));
|
||||||
}
|
}
|
||||||
setSelectedLocalIds(new Set());
|
setSelectedLocalIds(new Set());
|
||||||
} else {
|
} else {
|
||||||
const ids = selectedRemoteIds;
|
const ids = selectedRemoteIds;
|
||||||
addLog('command', `DELE [${ids.size} items]`);
|
const fileNames = Array.from(ids).map(id => remoteRef.current.files.find(f => f.id === id)?.name).filter(Boolean).join(', ');
|
||||||
|
addLog('command', `DELE ${fileNames}`);
|
||||||
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const file = remote.files.find(f => f.id === id);
|
const file = remoteRef.current.files.find(f => f.id === id);
|
||||||
if (file) {
|
if (file) {
|
||||||
const targetPath = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`;
|
const targetPath = remoteRef.current.path === '/' ? `/${file.name}` : `${remoteRef.current.path}/${file.name}`;
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
command: 'DELE',
|
command: 'DELE',
|
||||||
path: targetPath,
|
path: targetPath,
|
||||||
@@ -559,7 +690,7 @@ startServer();
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remote.path }));
|
wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
|
||||||
}, 500 + (ids.size * 50));
|
}, 500 + (ids.size * 50));
|
||||||
}
|
}
|
||||||
setSelectedRemoteIds(new Set());
|
setSelectedRemoteIds(new Set());
|
||||||
@@ -567,7 +698,101 @@ startServer();
|
|||||||
setActiveModal(null);
|
setActiveModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Conflict Processing
|
||||||
|
useEffect(() => {
|
||||||
|
if (conflictQueue.length > 0 && !activeConflict && !isConflictModalOpen) {
|
||||||
|
// Take first
|
||||||
|
const next = conflictQueue[0];
|
||||||
|
setActiveConflict(next);
|
||||||
|
setIsConflictModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [conflictQueue, activeConflict, isConflictModalOpen]);
|
||||||
|
|
||||||
|
const handleConflictOverwrite = (applyToAll: boolean) => {
|
||||||
|
if (!activeConflict) return;
|
||||||
|
|
||||||
|
const { source, direction } = activeConflict;
|
||||||
|
|
||||||
|
// Execute the transfer
|
||||||
|
const transferId = `${direction === 'upload' ? 'up' : 'down'}-${Date.now()}-${source.name}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: transferId,
|
||||||
|
direction,
|
||||||
|
filename: source.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: '-',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(source, direction, transferId);
|
||||||
|
|
||||||
|
// Handle Apply to All
|
||||||
|
if (applyToAll) {
|
||||||
|
setConflictResolution('overwrite');
|
||||||
|
// Process remaining immediately
|
||||||
|
const remaining = conflictQueue.slice(1);
|
||||||
|
remaining.forEach(c => {
|
||||||
|
const tid = `${c.direction === 'upload' ? 'up' : 'down'}-${Date.now()}-${c.source.name}`;
|
||||||
|
setQueue(prev => [...prev, {
|
||||||
|
id: tid,
|
||||||
|
direction: c.direction,
|
||||||
|
filename: c.source.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
speed: '-',
|
||||||
|
requestedAt: Date.now()
|
||||||
|
}]);
|
||||||
|
executeTransfer(c.source, c.direction, tid);
|
||||||
|
});
|
||||||
|
setConflictQueue([]);
|
||||||
|
} else {
|
||||||
|
setConflictQueue(prev => prev.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConflictModalOpen(false);
|
||||||
|
setActiveConflict(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConflictSkip = (applyToAll: boolean) => {
|
||||||
|
if (!activeConflict) return;
|
||||||
|
|
||||||
|
if (applyToAll) {
|
||||||
|
setConflictResolution('skip');
|
||||||
|
setConflictQueue([]);
|
||||||
|
} else {
|
||||||
|
setConflictQueue(prev => prev.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConflictModalOpen(false);
|
||||||
|
setActiveConflict(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelQueue = () => {
|
||||||
|
// Keep only completed/failed items
|
||||||
|
setQueue(prev => prev.filter(item => item.status === 'completed' || item.status === 'failed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCompleted = () => {
|
||||||
|
// Keep only queued/transferring items
|
||||||
|
setQueue(prev => prev.filter(item => item.status === 'queued' || item.status === 'transferring'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastConnectionRef = useRef<{ host: string; user: string } | null>(null);
|
||||||
|
|
||||||
const handleSiteConnect = (site: SiteConfig) => {
|
const handleSiteConnect = (site: SiteConfig) => {
|
||||||
|
// Check if connecting to a different server
|
||||||
|
const newConnectionKey = `${site.host}:${site.user}`;
|
||||||
|
const oldConnectionKey = lastConnectionRef.current ? `${lastConnectionRef.current.host}:${lastConnectionRef.current.user}` : null;
|
||||||
|
|
||||||
|
if (newConnectionKey !== oldConnectionKey) {
|
||||||
|
setQueue([]); // Clear queue for new server
|
||||||
|
}
|
||||||
|
lastConnectionRef.current = { host: site.host, user: site.user };
|
||||||
|
|
||||||
// 1. Update State
|
// 1. Update State
|
||||||
setConnection({
|
setConnection({
|
||||||
host: site.host,
|
host: site.host,
|
||||||
@@ -633,7 +858,6 @@ startServer();
|
|||||||
saveConnectionInfo={saveConnectionInfo}
|
saveConnectionInfo={saveConnectionInfo}
|
||||||
onToggleSaveConnectionInfo={setSaveConnectionInfo}
|
onToggleSaveConnectionInfo={setSaveConnectionInfo}
|
||||||
/>
|
/>
|
||||||
<ConnectionHelpModal isOpen={showConnectionHelp} onClose={() => setShowConnectionHelp(false)} />
|
|
||||||
<HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} initialTab={helpInitialTab} />
|
<HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} initialTab={helpInitialTab} />
|
||||||
<SiteManagerModal
|
<SiteManagerModal
|
||||||
isOpen={showSiteManager}
|
isOpen={showSiteManager}
|
||||||
@@ -660,6 +884,14 @@ startServer();
|
|||||||
onClose={() => setActiveModal(null)}
|
onClose={() => setActiveModal(null)}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
|
<ConflictModal
|
||||||
|
isOpen={isConflictModalOpen}
|
||||||
|
sourceFile={activeConflict ? { name: activeConflict.source.name, size: activeConflict.source.size, date: activeConflict.source.date } : { name: '', size: 0, date: '' }}
|
||||||
|
targetFile={activeConflict ? { name: activeConflict.target.name, size: activeConflict.target.size, date: activeConflict.target.date } : { name: '', size: 0, date: '' }}
|
||||||
|
onOverwrite={handleConflictOverwrite}
|
||||||
|
onSkip={handleConflictSkip}
|
||||||
|
onClose={() => handleConflictSkip(false)} // Treat close as skip single
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
||||||
@@ -745,13 +977,15 @@ startServer();
|
|||||||
{connection.connecting ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (connection.connected ? <><WifiOff size={16} /> 연결 해제</> : '빠른 연결')}
|
{connection.connecting ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (connection.connected ? <><WifiOff size={16} /> 연결 해제</> : '빠른 연결')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!(window as any).__IS_STANDALONE__ && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadBackend}
|
onClick={() => setShowDownloadModal(true)}
|
||||||
className={`h-[30px] flex items-center justify-center px-3 rounded bg-emerald-600 hover:bg-emerald-500 text-white border border-emerald-500 shadow-md shadow-emerald-500/20 transition-all ${!connection.connected ? 'animate-pulse ring-2 ring-emerald-400/50' : ''}`}
|
className={`h-[30px] flex items-center justify-center px-3 rounded bg-emerald-600 hover:bg-emerald-500 text-white border border-emerald-500 shadow-md shadow-emerald-500/20 transition-all ${!connection.connected ? 'animate-pulse ring-2 ring-emerald-400/50' : ''}`}
|
||||||
title="백엔드 다운로드"
|
title="다운로드 센터"
|
||||||
>
|
>
|
||||||
<Download size={14} className="mr-1.5" /> 백엔드
|
<Download size={14} className="mr-1.5" /> 다운로드
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHelp(true)}
|
onClick={() => setShowHelp(true)}
|
||||||
@@ -777,13 +1011,10 @@ startServer();
|
|||||||
<div className="w-1/2 min-w-0 h-full">
|
<div className="w-1/2 min-w-0 h-full">
|
||||||
<LogConsole logs={logs} />
|
<LogConsole logs={logs} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 min-w-0 h-full bg-white border border-slate-300 rounded-lg shadow-sm flex flex-col items-center justify-center relative overflow-hidden group cursor-pointer">
|
<div className="w-1/2 min-w-0 h-full bg-white border border-slate-300 rounded-lg shadow-sm flex flex-col items-center justify-center relative overflow-hidden">
|
||||||
|
{/* Placeholder for Sponsored tag if needed, or remove it */}
|
||||||
<div className="absolute top-0 right-0 bg-slate-100 text-[9px] text-slate-500 px-1.5 py-0.5 rounded-bl border-b border-l border-slate-200 z-10">SPONSORED</div>
|
<div className="absolute top-0 right-0 bg-slate-100 text-[9px] text-slate-500 px-1.5 py-0.5 rounded-bl border-b border-l border-slate-200 z-10">SPONSORED</div>
|
||||||
<div className="text-slate-300 flex flex-col items-center gap-1 group-hover:text-slate-400 transition-colors">
|
<AdSenseBanner />
|
||||||
<div className="font-bold text-lg tracking-widest flex items-center gap-2"><MousePointerClick size={20} /> GOOGLE ADSENSE</div>
|
|
||||||
<div className="text-xs">Premium Ad Space Available</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 -z-0 opacity-30" style={{ backgroundImage: 'radial-gradient(#cbd5e1 1px, transparent 1px)', backgroundSize: '10px 10px' }}></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -811,10 +1042,20 @@ startServer();
|
|||||||
|
|
||||||
{/* Middle Actions */}
|
{/* Middle Actions */}
|
||||||
<div className="flex md:flex-col items-center justify-center gap-2 p-1">
|
<div className="flex md:flex-col items-center justify-center gap-2 p-1">
|
||||||
<button className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-blue-50 text-slate-400">
|
<button
|
||||||
|
className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-blue-50 text-slate-400 hover:text-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!connection.connected || selectedLocalIds.size === 0}
|
||||||
|
title="업로드 (Local -> Remote)"
|
||||||
|
>
|
||||||
<ArrowRight size={24} strokeWidth={2.5} />
|
<ArrowRight size={24} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-green-50 text-slate-400">
|
<button
|
||||||
|
className="p-3 bg-white border border-slate-300 shadow-sm rounded hover:bg-green-50 text-slate-400 hover:text-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!connection.connected || selectedRemoteIds.size === 0}
|
||||||
|
title="다운로드 (Remote -> Local)"
|
||||||
|
>
|
||||||
<ArrowLeft size={24} strokeWidth={2.5} />
|
<ArrowLeft size={24} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,9 +1083,19 @@ startServer();
|
|||||||
|
|
||||||
{/* Queue */}
|
{/* Queue */}
|
||||||
<div className="h-48 shrink-0 p-2 pt-0">
|
<div className="h-48 shrink-0 p-2 pt-0">
|
||||||
<TransferQueue queue={queue} />
|
<TransferQueue
|
||||||
|
queue={queue}
|
||||||
|
onCancelAll={handleCancelQueue}
|
||||||
|
onClearCompleted={handleClearCompleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download Modal - Standalone Only */}
|
||||||
|
<DownloadModal
|
||||||
|
isOpen={showDownloadModal}
|
||||||
|
onClose={() => setShowDownloadModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white border-t border-slate-200 px-3 py-1 text-[10px] text-slate-500 flex justify-between shadow-[0_-2px_10px_rgba(0,0,0,0.02)]">
|
<footer className="bg-white border-t border-slate-200 px-3 py-1 text-[10px] text-slate-500 flex justify-between shadow-[0_-2px_10px_rgba(0,0,0,0.02)]">
|
||||||
<span>WebZilla v1.3.0 <span className="mx-2 text-slate-300">|</span> © SIMP</span>
|
<span>WebZilla v1.3.0 <span className="mx-2 text-slate-300">|</span> © SIMP</span>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
|
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const APP_VERSION = "0.0.1";
|
||||||
|
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const ftp = require('basic-ftp');
|
const ftp = require('basic-ftp');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -38,9 +40,102 @@ const PORT = 8090;
|
|||||||
|
|
||||||
// --- 서버 시작 함수 (재시도 로직 포함) ---
|
// --- 서버 시작 함수 (재시도 로직 포함) ---
|
||||||
function startServer() {
|
function startServer() {
|
||||||
const wss = new WebSocket.Server({ port: PORT });
|
const http = require('http'); // HTTP server module required
|
||||||
|
|
||||||
wss.on('error', (err) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
// Parse URL to handle query strings and decoding
|
||||||
|
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const pathname = decodeURIComponent(parsedUrl.pathname);
|
||||||
|
|
||||||
|
// Special handling for executables (Download Center)
|
||||||
|
if (pathname === '/webftp.exe' || pathname === '/webftp-backend.exe') {
|
||||||
|
const exeDir = path.dirname(process.execPath);
|
||||||
|
// Fallback for dev mode -> serve from public
|
||||||
|
const targetPath = process.pkg ? path.join(exeDir, pathname) : path.join(__dirname, 'public', pathname);
|
||||||
|
|
||||||
|
if (fs.existsSync(targetPath)) {
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': stat.size,
|
||||||
|
'Content-Disposition': `attachment; filename="${path.basename(targetPath)}"`
|
||||||
|
});
|
||||||
|
const readStream = fs.createReadStream(targetPath);
|
||||||
|
readStream.pipe(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static File Serving
|
||||||
|
// Handle /ftp base path (strip it)
|
||||||
|
let normalizedPath = pathname;
|
||||||
|
if (normalizedPath.startsWith('/ftp')) {
|
||||||
|
normalizedPath = normalizedPath.replace(/^\/ftp/, '') || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath = path.join(__dirname, 'dist', normalizedPath === '/' ? 'index.html' : normalizedPath);
|
||||||
|
|
||||||
|
// Prevent traversal
|
||||||
|
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
|
||||||
|
res.writeHead(403); res.end('Forbidden'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extname = path.extname(filePath);
|
||||||
|
// Basic MIME types
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
||||||
|
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml', '.ico': 'image/x-icon'
|
||||||
|
};
|
||||||
|
let contentType = MIME_TYPES[extname] || 'application/octet-stream';
|
||||||
|
|
||||||
|
fs.readFile(filePath, (err, content) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
// SPA fallback
|
||||||
|
if (!extname || extname === '.html') {
|
||||||
|
fs.readFile(path.join(__dirname, 'dist', 'index.html'), (err2, content2) => {
|
||||||
|
if (err2) {
|
||||||
|
// If index.html is missing (Backend Only Mode), return 404
|
||||||
|
console.log(`[404] Backend Only Mode - Missing: ${pathname}`);
|
||||||
|
res.writeHead(404); res.end('Backend Only Mode - No Frontend Assets Found');
|
||||||
|
} else {
|
||||||
|
let html = content2.toString('utf-8');
|
||||||
|
if (process.pkg) {
|
||||||
|
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`[404] File Not Found: ${pathname}`);
|
||||||
|
res.writeHead(404); res.end('File Not Found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[500] Server Error for ${pathname}:`, err.code);
|
||||||
|
res.writeHead(500); res.end(`Server Error: ${err.code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Determine if we need to inject script for main index.html access
|
||||||
|
if (filePath.endsWith('index.html') || extname === '.html') {
|
||||||
|
let html = content.toString('utf-8');
|
||||||
|
if (process.pkg) {
|
||||||
|
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(html);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(content, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
if (err.code === 'EADDRINUSE') {
|
||||||
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
|
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
|
||||||
handlePortConflict();
|
handlePortConflict();
|
||||||
@@ -50,11 +145,21 @@ function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on('listening', () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`\n🚀 WebZilla FTP Proxy Server가 ws://localhost:${PORT} 에서 실행 중입니다.`);
|
console.log(`\n🚀 WebZilla Server [v${APP_VERSION}] running at http://localhost:${PORT}`);
|
||||||
console.log(`📂 설정 폴더: ${configDir}`);
|
console.log(`📂 Config: ${configDir}`);
|
||||||
|
|
||||||
|
// Check if Frontend Assets exist (dist/index.html)
|
||||||
|
const frontendExists = fs.existsSync(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
if (frontendExists) {
|
||||||
|
console.log("✨ Frontend detected. Launching browser...");
|
||||||
|
openBrowser(`http://localhost:${PORT}/ftp/`);
|
||||||
|
} else {
|
||||||
|
console.log("🔧 Backend Only Mode. Waiting for connections...");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WSS Connection Handling (Existing Logic)
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
const client = new ftp.Client();
|
const client = new ftp.Client();
|
||||||
|
|
||||||
@@ -80,6 +185,7 @@ function startServer() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
||||||
case 'LIST':
|
case 'LIST':
|
||||||
if (client.closed) {
|
if (client.closed) {
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
||||||
@@ -236,6 +342,82 @@ function startServer() {
|
|||||||
ws.send(JSON.stringify({ type: 'error', message: `삭제 실패: ${err.message}` }));
|
ws.send(JSON.stringify({ type: 'error', message: `삭제 실패: ${err.message}` }));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'DOWNLOAD':
|
||||||
|
if (client.closed) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { remotePath, localPath, transferId } = data;
|
||||||
|
|
||||||
|
// Progress Handler
|
||||||
|
client.trackProgress(info => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_progress',
|
||||||
|
id: transferId,
|
||||||
|
bytes: info.bytes,
|
||||||
|
bytesOverall: info.bytesOverall,
|
||||||
|
name: info.name
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.downloadTo(localPath, remotePath);
|
||||||
|
|
||||||
|
client.trackProgress(); // Stop tracking
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_success',
|
||||||
|
id: transferId,
|
||||||
|
message: '다운로드 완료',
|
||||||
|
path: localPath
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
client.trackProgress(); // Stop tracking
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_error',
|
||||||
|
id: data.transferId,
|
||||||
|
message: `다운로드 실패: ${err.message}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'UPLOAD':
|
||||||
|
if (client.closed) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { remotePath, localPath, transferId } = data;
|
||||||
|
|
||||||
|
// Progress Handler
|
||||||
|
client.trackProgress(info => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_progress',
|
||||||
|
id: transferId,
|
||||||
|
bytes: info.bytes,
|
||||||
|
bytesOverall: info.bytesOverall,
|
||||||
|
name: info.name
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.uploadFrom(localPath, remotePath);
|
||||||
|
|
||||||
|
client.trackProgress();
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_success',
|
||||||
|
id: transferId,
|
||||||
|
message: '업로드 완료',
|
||||||
|
path: remotePath
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
client.trackProgress();
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transfer_error',
|
||||||
|
id: data.transferId,
|
||||||
|
message: `업로드 실패: ${err.message}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("오류 발생:", err);
|
console.error("오류 발생:", err);
|
||||||
@@ -305,5 +487,15 @@ function askToKill(pid) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 브라우저 열기 ---
|
||||||
|
function openBrowser(url) {
|
||||||
|
const start = (process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open');
|
||||||
|
exec(`${start} ${url}`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log("브라우저를 자동으로 열 수 없습니다:", err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 초기 실행
|
// 초기 실행
|
||||||
startServer();
|
startServer();
|
||||||
104
components/ConflictModal.tsx
Normal file
104
components/ConflictModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle, X, Check, ArrowRight } from 'lucide-react';
|
||||||
|
import { formatBytes } from '../utils/formatters';
|
||||||
|
|
||||||
|
interface ConflictModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
sourceFile: { name: string; size: number; date: string };
|
||||||
|
targetFile: { name: string; size: number; date: string };
|
||||||
|
onOverwrite: (applyToAll: boolean) => void;
|
||||||
|
onSkip: (applyToAll: boolean) => void;
|
||||||
|
onClose: () => void; // Usually acts as cancel/skip logic if forced closed
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConflictModal: React.FC<ConflictModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
sourceFile,
|
||||||
|
targetFile,
|
||||||
|
onOverwrite,
|
||||||
|
onSkip,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [applyToAll, setApplyToAll] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-[500px] max-w-full m-4 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-yellow-50 px-4 py-3 border-b border-yellow-100 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-700">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<h3 className="font-bold text-sm">파일 중복 확인 (File Conflict)</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 flex flex-col gap-4">
|
||||||
|
<p className="text-slate-700 text-sm">
|
||||||
|
대상 위치에 동일한 이름의 파일이 이미 존재합니다. 어떻게 처리하시겠습니까?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded p-4 text-xs flex flex-col gap-3">
|
||||||
|
{/* Target (Existing) */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col gap-1 w-1/2 pr-2 border-r border-slate-200">
|
||||||
|
<span className="font-semibold text-slate-500">기존 파일 (Target)</span>
|
||||||
|
<span className="font-medium text-slate-800 truncate" title={targetFile.name}>{targetFile.name}</span>
|
||||||
|
<div className="flex gap-2 text-slate-500">
|
||||||
|
<span>{formatBytes(targetFile.size)}</span>
|
||||||
|
<span>{new Date(targetFile.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source (New) */}
|
||||||
|
<div className="flex flex-col gap-1 w-1/2 pl-2 text-right">
|
||||||
|
<span className="font-semibold text-blue-600">새 파일 (Source)</span>
|
||||||
|
<span className="font-medium text-slate-800 truncate" title={sourceFile.name}>{sourceFile.name}</span>
|
||||||
|
<div className="flex gap-2 justify-end text-slate-500">
|
||||||
|
<span>{formatBytes(sourceFile.size)}</span>
|
||||||
|
<span>{new Date(sourceFile.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="applyToAll"
|
||||||
|
checked={applyToAll}
|
||||||
|
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||||
|
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="applyToAll" className="text-sm text-slate-600 cursor-pointer select-none">
|
||||||
|
이후 모든 중복 파일에 대해 동일하게 적용
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-slate-50 px-4 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onSkip(applyToAll)}
|
||||||
|
className="px-4 py-2 bg-white border border-slate-300 rounded text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
건너뛰기 (Skip)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOverwrite(applyToAll)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
덮어쓰기 (Overwrite)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConflictModal;
|
||||||
119
components/DownloadModal.tsx
Normal file
119
components/DownloadModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Download, Package, Server, X } from 'lucide-react';
|
||||||
|
import { formatBytes } from '../utils/formatters';
|
||||||
|
|
||||||
|
interface DownloadModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadModal: React.FC<DownloadModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const [sizes, setSizes] = useState({ full: 0, backend: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Fetch sizes
|
||||||
|
const fetchSize = async (url: string, key: 'full' | 'backend') => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: 'HEAD' });
|
||||||
|
const len = res.headers.get('content-length');
|
||||||
|
if (len) setSizes(prev => ({ ...prev, [key]: parseInt(len, 10) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch size", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSize(`${import.meta.env.BASE_URL}webftp.exe`, 'full');
|
||||||
|
fetchSize(`${import.meta.env.BASE_URL}webftp-backend.exe`, 'backend');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-[500px] max-w-full m-4 overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 bg-slate-50">
|
||||||
|
<h3 className="font-bold text-slate-800 flex items-center gap-2">
|
||||||
|
<Download size={20} className="text-blue-600" />
|
||||||
|
WebZilla 다운로드 센터
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
|
사용 환경에 맞는 실행 파일을 선택하여 다운로드하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* Option 1: Full Version */}
|
||||||
|
<a
|
||||||
|
href={`${import.meta.env.BASE_URL}webftp.exe`}
|
||||||
|
download="webftp.exe"
|
||||||
|
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 hover:border-blue-400 hover:bg-blue-50 transition-all group relative"
|
||||||
|
>
|
||||||
|
<div className="p-3 bg-blue-100 text-blue-600 rounded-lg group-hover:bg-blue-200 transition-colors">
|
||||||
|
<Package size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-bold text-slate-800">단일 실행 파일 (All-in-One)</h4>
|
||||||
|
<span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded text-slate-500">
|
||||||
|
{sizes.full > 0 ? formatBytes(sizes.full) : 'Loading...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
설치가 필요 없으며, 프론트엔드와 백엔드가 하나로 통합되어 있습니다.
|
||||||
|
<span className="block text-blue-600 mt-1 font-semibold">가장 추천하는 방식입니다.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Download size={16} className="absolute top-4 right-4 text-slate-300 group-hover:text-blue-500" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Option 2: Backend Only */}
|
||||||
|
<a
|
||||||
|
href={`${import.meta.env.BASE_URL}webftp-backend.exe`}
|
||||||
|
download="webftp-backend.exe"
|
||||||
|
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 hover:border-emerald-400 hover:bg-emerald-50 transition-all group relative"
|
||||||
|
>
|
||||||
|
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-lg group-hover:bg-emerald-200 transition-colors">
|
||||||
|
<Server size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-bold text-slate-800">백엔드 전용 (Backend Only)</h4>
|
||||||
|
<span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded text-slate-500">
|
||||||
|
{sizes.backend > 0 ? formatBytes(sizes.backend) : 'Loading...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
정적 파일 호스팅을 별도로 하거나, 서버 리소스를 최소화해야 할 때 사용하세요.
|
||||||
|
(프론트엔드 파일 미포함)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Download size={16} className="absolute top-4 right-4 text-slate-300 group-hover:text-emerald-500" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-3 bg-slate-50 border-t border-slate-100 text-right">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-white border border-slate-300 text-slate-700 rounded hover:bg-slate-50 text-sm font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadModal;
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { TransferItem } from '../types';
|
import { TransferItem } from '../types';
|
||||||
import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock } from 'lucide-react';
|
import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface TransferQueueProps {
|
interface TransferQueueProps {
|
||||||
queue: TransferItem[];
|
queue: TransferItem[];
|
||||||
|
onCancelAll: () => void;
|
||||||
|
onClearCompleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
const TransferQueue: React.FC<TransferQueueProps> = ({ queue, onCancelAll, onClearCompleted }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
|
||||||
|
|
||||||
|
const filteredQueue = queue.filter(item => {
|
||||||
|
if (activeTab === 'active') {
|
||||||
|
return item.status === 'queued' || item.status === 'transferring';
|
||||||
|
} else {
|
||||||
|
return item.status === 'completed' || item.status === 'failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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">
|
||||||
<div className="bg-slate-50 px-3 py-2 border-b border-slate-200 flex justify-between items-center">
|
<div className="bg-slate-50 px-3 py-1 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="flex space-x-2">
|
||||||
<div className="text-xs text-slate-500">
|
<button
|
||||||
{queue.filter(i => i.status === 'transferring').length} 개 진행 중
|
className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'active' ? 'bg-white text-blue-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
>
|
||||||
|
전송 대기 ({queue.filter(i => i.status === 'queued' || i.status === 'transferring').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'completed' ? 'bg-white text-emerald-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
onClick={() => setActiveTab('completed')}
|
||||||
|
>
|
||||||
|
전송 완료 ({queue.filter(i => i.status === 'completed' || i.status === 'failed').length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'active' && filteredQueue.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onCancelAll}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
모두 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeTab === 'completed' && filteredQueue.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onClearCompleted}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:bg-slate-200 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
목록 제거
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-white">
|
<div className="flex-1 overflow-y-auto bg-white">
|
||||||
@@ -22,13 +63,16 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 w-8"></th>
|
<th className="p-2 w-8"></th>
|
||||||
<th className="p-2">파일명</th>
|
<th className="p-2">파일명</th>
|
||||||
<th className="p-2 w-24">구분</th>
|
<th className="p-2 w-20">구분</th>
|
||||||
<th className="p-2 w-48">진행률</th>
|
<th className="p-2 w-40 text-center">요청</th>
|
||||||
|
<th className="p-2 w-40 text-center">완료</th>
|
||||||
|
<th className="p-2 w-32 text-center">소요</th>
|
||||||
|
<th className="p-2 w-32">진행률</th>
|
||||||
<th className="p-2 w-24 text-right">속도</th>
|
<th className="p-2 w-24 text-right">속도</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{queue.map((item) => (
|
{filteredQueue.map((item) => (
|
||||||
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
|
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
|
||||||
<td className="p-2 text-center">
|
<td className="p-2 text-center">
|
||||||
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
|
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
|
||||||
@@ -38,18 +82,26 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
|
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 truncate max-w-[200px] font-medium">{item.filename}</td>
|
<td className="p-2 truncate max-w-[150px] font-medium" title={item.filename}>{item.filename}</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
|
<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' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
|
||||||
{item.direction === 'upload' ? '업로드' : '다운로드'}
|
{item.direction === 'upload' ? '업로드' : '다운로드'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.requestedAt ? new Date(item.requestedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.completedAt ? new Date(item.completedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.completedAt && item.requestedAt ? `${((item.completedAt - item.requestedAt) / 1000).toFixed(1)}s` : '-'}
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
|
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-all duration-200 ${
|
className={`h-full transition-all duration-200 ${item.status === 'completed' ? 'bg-emerald-500' :
|
||||||
item.status === 'completed' ? 'bg-emerald-500' :
|
|
||||||
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
|
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${item.progress}%` }}
|
style={{ width: `${item.progress}%` }}
|
||||||
@@ -59,10 +111,10 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
|
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{queue.length === 0 && (
|
{filteredQueue.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="p-8 text-center text-slate-400 italic">
|
<td colSpan={8} className="p-8 text-center text-slate-400 italic">
|
||||||
전송 대기열이 비어있습니다.
|
{activeTab === 'active' ? '대기 중인 전송 파일이 없습니다.' : '완료된 전송 내역이 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WebZilla - Cloud FTP Client</title>
|
<title>WebFTP by SIMP</title>
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4444852135420953"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<style>
|
<style>
|
||||||
/* Custom scrollbar for a more native app feel - Light Theme */
|
/* Custom scrollbar for a more native app feel - Light Theme */
|
||||||
|
|||||||
2
make.bat
2
make.bat
@@ -1 +1 @@
|
|||||||
pkg backend_proxy.cjs --targets node18-win-x64 --output webftp-backend.exe
|
pkg backend_proxy.cjs --targets node18-win-x64 --output .\public\webftp-backend.exe
|
||||||
|
|||||||
1776
package-lock.json
generated
1776
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -3,9 +3,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": "backend_proxy.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"clean": "rimraf public/webftp.exe public/webftp-backend.exe dist/webftp.exe dist/webftp-backend.exe",
|
||||||
|
"build": "npm run clean && vite build && npm run build:backend && npm run build:full",
|
||||||
|
"build:full": "pkg . --output ./public/webftp.exe",
|
||||||
|
"build:backend": "pkg backend_proxy.cjs -c pkg-backend.json --output ./public/webftp-backend.exe",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"proxy": "node backend_proxy.cjs"
|
"proxy": "node backend_proxy.cjs"
|
||||||
},
|
},
|
||||||
@@ -20,7 +24,18 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"pkg": "^5.8.1",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"pkg": {
|
||||||
|
"scripts": "backend_proxy.cjs",
|
||||||
|
"assets": [
|
||||||
|
"dist/**/*"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"node18-win-x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
pkg-backend.json
Normal file
12
pkg-backend.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "webzilla-backend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"bin": "backend_proxy.cjs",
|
||||||
|
"pkg": {
|
||||||
|
"scripts": "backend_proxy.cjs",
|
||||||
|
"assets": [],
|
||||||
|
"targets": [
|
||||||
|
"node18-win-x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
types.ts
2
types.ts
@@ -27,6 +27,8 @@ export interface TransferItem {
|
|||||||
progress: number; // 0 to 100
|
progress: number; // 0 to 100
|
||||||
status: 'queued' | 'transferring' | 'completed' | 'failed';
|
status: 'queued' | 'transferring' | 'completed' | 'failed';
|
||||||
speed: string;
|
speed: string;
|
||||||
|
requestedAt?: number; // Timestamp
|
||||||
|
completedAt?: number; // Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileSystemState {
|
export interface FileSystemState {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user