Compare commits

...

10 Commits

Author SHA1 Message Date
backuppc
b31b3c6d31 Enhance download center with file size preview and dual options 2026-01-21 15:11:57 +09:00
backuppc
fc9500f99b 로컬실행모드추가 및 로컬실행시에는 백엔드 버튼 숨김 2026-01-21 14:43:10 +09:00
backuppc
c84fd2a7e9 광고추가 2026-01-21 14:25:55 +09:00
backuppc
a1a1971a1f Implement Transfer Queue Enhancements, Smart Queue Clearing, and File Conflict Resolution 2026-01-20 17:29:57 +09:00
backuppc
b3a6d74f1e localRefremoteRef를 도입하여 WebSocket 명령이 항상 최신 localremote 상태를 사용하도록 합니다. 2026-01-20 15:21:23 +09:00
backuppc
467d0f5917 백엔드 버젼표시 2026-01-20 15:13:21 +09:00
ea070816e3 Delete public/webftp-backend.exe 2026-01-20 06:12:36 +00:00
backuppc
86729014d3 .. 2026-01-20 15:08:33 +09:00
backuppc
e28de5f2c3 백엔드파일 추적 제외 2026-01-20 15:08:24 +09:00
backuppc
32fee75e69 make backend file 2026-01-20 15:07:36 +09:00
14 changed files with 2416 additions and 337 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
public/webftp-backend.exe
public/webftp.exe

460
App.tsx
View File

@@ -8,8 +8,32 @@ import SettingsModal from './components/SettingsModal';
import SiteManagerModal from './components/SiteManagerModal';
import HelpModal from './components/HelpModal';
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 = () => {
// --- State ---
const savedPref = localStorage.getItem('save_connection_info') !== 'false';
@@ -35,14 +59,21 @@ const App: React.FC = () => {
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
const [showSiteManager, setShowSiteManager] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showDownloadModal, setShowDownloadModal] = useState(false);
const [helpInitialTab, setHelpInitialTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
// 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 [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
const [local, setLocal] = useState<FileSystemState>({
path: localStorage.getItem('last_local_path') || '',
@@ -62,6 +93,8 @@ const App: React.FC = () => {
// WebSocket
const wsRef = useRef<WebSocket | null>(null);
const connectionRef = useRef(connection);
const localRef = useRef(local);
const remoteRef = useRef(remote);
// --- Helpers ---
const addLog = useCallback((type: LogEntry['type'], message: string) => {
@@ -101,7 +134,9 @@ const App: React.FC = () => {
// Sync connection state to ref for WS callbacks
useEffect(() => {
connectionRef.current = connection;
}, [connection]);
localRef.current = local;
remoteRef.current = remote;
}, [connection, local, remote]);
// --- WebSocket Setup ---
useEffect(() => {
@@ -132,55 +167,6 @@ const App: React.FC = () => {
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) => {
try {
const data = JSON.parse(event.data);
@@ -211,6 +197,21 @@ const App: React.FC = () => {
}
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':
const sortedRemoteFiles = data.files
.map((f: any) => ({
@@ -264,9 +265,9 @@ const App: React.FC = () => {
case 'success':
addLog('success', data.message);
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;
case 'sites_list':
@@ -288,16 +289,16 @@ const App: React.FC = () => {
case 'transfer_success':
setQueue(prev => prev.map(item => {
if (item.id === data.id) {
return { ...item, progress: 100, status: 'completed', speed: 'Completed' };
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: local.path }));
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
if (connectionRef.current.connected) {
ws?.send(JSON.stringify({ command: 'LIST', path: remote.path }));
ws?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
}
}
break;
@@ -305,7 +306,7 @@ const App: React.FC = () => {
case 'transfer_error':
setQueue(prev => prev.map(item => {
if (item.id === data.id) {
return { ...item, status: 'failed', speed: data.message };
return { ...item, status: 'failed', speed: data.message, completedAt: Date.now() };
}
return item;
}));
@@ -405,71 +406,156 @@ const App: React.FC = () => {
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;
selectedRemoteIds.forEach(id => {
const file = remote.files.find(f => f.id === id);
if (file && file.type === FileType.FILE) {
const transferId = `down-${Date.now()}-${Math.random()}`;
const separator = local.path.includes('\\') ? '\\' : '/';
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
const localTarget = cleanPath + file.name;
const remoteTarget = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`;
// Filter and Process
const selectedFiles = remote.files.filter(f => selectedRemoteIds.has(f.id));
const conflicts: { source: FileItem, target: FileItem, direction: 'download' }[] = [];
const safeTransfers: FileItem[] = [];
// Add to Queue
setQueue(prev => [...prev, {
id: transferId,
direction: 'download',
filename: file.name,
progress: 0,
status: 'queued',
speed: 'Pending...'
}]);
selectedFiles.forEach(file => {
if (file.type !== FileType.FILE) return;
addLog('command', `DOWNLOAD ${file.name} -> ${localTarget}`);
wsRef.current?.send(JSON.stringify({
command: 'DOWNLOAD',
localPath: localTarget,
remotePath: remoteTarget,
transferId
}));
// 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;
selectedLocalIds.forEach(id => {
const file = local.files.find(f => f.id === id);
if (file && file.type === FileType.FILE) {
const transferId = `up-${Date.now()}-${Math.random()}`;
const separator = local.path.includes('\\') ? '\\' : '/';
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
const localSource = cleanPath + file.name;
const remoteTarget = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`;
const selectedFiles = local.files.filter(f => selectedLocalIds.has(f.id));
const conflicts: { source: FileItem, target: FileItem, direction: 'upload' }[] = [];
const safeTransfers: FileItem[] = [];
// Add to Queue
setQueue(prev => [...prev, {
id: transferId,
direction: 'upload',
filename: file.name,
progress: 0,
status: 'queued',
speed: 'Pending...'
}]);
selectedFiles.forEach(file => {
if (file.type !== FileType.FILE) return;
addLog('command', `UPLOAD ${file.name} -> ${remoteTarget}`);
wsRef.current?.send(JSON.stringify({
command: 'UPLOAD',
localPath: localSource,
remotePath: remoteTarget,
transferId
}));
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());
};
@@ -567,31 +653,35 @@ const App: React.FC = () => {
const handleDeleteConfirm = () => {
if (modalTargetIsLocal) {
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) {
const separator = local.path.includes('\\') ? '\\' : '/';
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
const separator = localRef.current.path.includes('\\') ? '\\' : '/';
const cleanPath = localRef.current.path.endsWith(separator) ? localRef.current.path : localRef.current.path + separator;
ids.forEach(id => {
const file = local.files.find(f => f.id === id);
const file = localRef.current.files.find(f => f.id === id);
if (file) {
const targetPath = cleanPath + file.name;
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath }));
}
});
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
}, 500 + (ids.size * 50)); // Delay a bit more for multiple deletes
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
}, 500 + (ids.size * 50));
}
setSelectedLocalIds(new Set());
} else {
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) {
ids.forEach(id => {
const file = remote.files.find(f => f.id === id);
const file = remoteRef.current.files.find(f => f.id === id);
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({
command: 'DELE',
path: targetPath,
@@ -600,7 +690,7 @@ const App: React.FC = () => {
}
});
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));
}
setSelectedRemoteIds(new Set());
@@ -608,7 +698,101 @@ const App: React.FC = () => {
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) => {
// 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
setConnection({
host: site.host,
@@ -700,6 +884,14 @@ const App: React.FC = () => {
onClose={() => setActiveModal(null)}
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 className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
@@ -785,14 +977,15 @@ const App: React.FC = () => {
{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>
<a
href={`${import.meta.env.BASE_URL}webftp-backend.exe`}
download="webftp-backend.exe"
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="백엔드 다운로드"
>
<Download size={14} className="mr-1.5" />
</a>
{!(window as any).__IS_STANDALONE__ && (
<button
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' : ''}`}
title="다운로드 센터"
>
<Download size={14} className="mr-1.5" />
</button>
)}
<button
onClick={() => setShowHelp(true)}
@@ -818,13 +1011,10 @@ const App: React.FC = () => {
<div className="w-1/2 min-w-0 h-full">
<LogConsole logs={logs} />
</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="text-slate-300 flex flex-col items-center gap-1 group-hover:text-slate-400 transition-colors">
<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>
<AdSenseBanner />
</div>
</div>
@@ -893,9 +1083,19 @@ const App: React.FC = () => {
{/* Queue */}
<div className="h-48 shrink-0 p-2 pt-0">
<TransferQueue queue={queue} />
<TransferQueue
queue={queue}
onCancelAll={handleCancelQueue}
onClearCompleted={handleClearCompleted}
/>
</div>
{/* Download Modal - Standalone Only */}
<DownloadModal
isOpen={showDownloadModal}
onClose={() => setShowDownloadModal(false)}
/>
{/* 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)]">
<span>WebZilla v1.3.0 <span className="mx-2 text-slate-300">|</span> © SIMP</span>

View File

@@ -9,6 +9,8 @@
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
*/
const APP_VERSION = "0.0.1";
const WebSocket = require('ws');
const ftp = require('basic-ftp');
const fs = require('fs');
@@ -38,9 +40,102 @@ const PORT = 8090;
// --- 서버 시작 함수 (재시도 로직 포함) ---
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') {
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
handlePortConflict();
@@ -50,11 +145,21 @@ function startServer() {
}
});
wss.on('listening', () => {
console.log(`\n🚀 WebZilla FTP Proxy Server가 ws://localhost:${PORT} 에서 실행 중입니다.`);
console.log(`📂 설정 폴더: ${configDir}`);
server.listen(PORT, () => {
console.log(`\n🚀 WebZilla Server [v${APP_VERSION}] running at http://localhost:${PORT}`);
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) => {
const client = new ftp.Client();
@@ -80,6 +185,7 @@ function startServer() {
}
break;
case 'LIST':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
@@ -239,12 +345,12 @@ function startServer() {
case 'DOWNLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
@@ -257,32 +363,32 @@ function startServer() {
});
await client.downloadTo(localPath, remotePath);
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_success',
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '다운로드 완료',
path: localPath
path: localPath
}));
} catch (err) {
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_error',
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `다운로드 실패: ${err.message}`
message: `다운로드 실패: ${err.message}`
}));
}
break;
case 'UPLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
@@ -295,20 +401,20 @@ function startServer() {
});
await client.uploadFrom(localPath, remotePath);
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_success',
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '업로드 완료',
path: remotePath
}));
} catch (err) {
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_error',
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `업로드 실패: ${err.message}`
message: `업로드 실패: ${err.message}`
}));
}
break;
@@ -381,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();

View 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;

View 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;

View File

@@ -1,34 +1,78 @@
import React, { useEffect, useRef } from 'react';
import React, { useState } from 'react';
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 {
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 (
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
<div className="bg-slate-50 px-3 py-2 border-b border-slate-200 flex justify-between items-center">
<span className="text-slate-600 text-xs font-semibold uppercase tracking-wider"> (Queue)</span>
<div className="text-xs text-slate-500">
{queue.filter(i => i.status === 'transferring').length}
<div className="bg-slate-50 px-3 py-1 border-b border-slate-200 flex justify-between items-center">
<div className="flex space-x-2">
<button
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>
{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 className="flex-1 overflow-y-auto bg-white">
<table className="w-full text-xs text-left border-collapse">
<thead className="bg-slate-50 text-slate-500 sticky top-0 border-b border-slate-200">
<tr>
<th className="p-2 w-8"></th>
<th className="p-2"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-48"></th>
<th className="p-2 w-20"></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>
</tr>
</thead>
<tbody>
{queue.map((item) => (
{filteredQueue.map((item) => (
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
<td className="p-2 text-center">
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
@@ -38,20 +82,28 @@ 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>
)}
</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">
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
{item.direction === 'upload' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
{item.direction === 'upload' ? '업로드' : '다운로드'}
</span>
</td>
<td className="p-2 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">
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-200 ${
item.status === 'completed' ? 'bg-emerald-500' :
<div
className={`h-full transition-all duration-200 ${item.status === 'completed' ? 'bg-emerald-500' :
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
}`}
}`}
style={{ width: `${item.progress}%` }}
/>
</div>
@@ -59,10 +111,10 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
</tr>
))}
{queue.length === 0 && (
{filteredQueue.length === 0 && (
<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>
</tr>
)}

View File

@@ -5,6 +5,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<style>
/* Custom scrollbar for a more native app feel - Light Theme */

View File

@@ -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

1777
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,13 @@
"private": true,
"version": "0.0.0",
"type": "module",
"bin": "backend_proxy.cjs",
"scripts": {
"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",
"proxy": "node backend_proxy.cjs"
},
@@ -20,7 +24,18 @@
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"pkg": "^5.8.1",
"rimraf": "^6.1.2",
"typescript": "~5.8.2",
"vite": "^6.2.0"
},
"pkg": {
"scripts": "backend_proxy.cjs",
"assets": [
"dist/**/*"
],
"targets": [
"node18-win-x64"
]
}
}
}

12
pkg-backend.json Normal file
View 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"
]
}
}

Binary file not shown.

View File

@@ -27,6 +27,8 @@ export interface TransferItem {
progress: number; // 0 to 100
status: 'queued' | 'transferring' | 'completed' | 'failed';
speed: string;
requestedAt?: number; // Timestamp
completedAt?: number; // Timestamp
}
export interface FileSystemState {

Binary file not shown.