const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, { value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' }, { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' } ]; // --- State --- let selectedFiles = []; // { path, name, size } let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let editingAccountId = null; // null = adding, string = editing account by ID let autoHealthCheckEnabled = true; let queuePersistTimer = null; let settingsSaveTimer = null; let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; // Queue state let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link } let _jobIndexById = new Map(); // id -> job (O(1) lookup) let _jobIndexByUploadId = new Map(); // uploadId -> job let selectedJobIds = new Set(); let _sessionTotalBytes = 0; // Total bytes ever added to queue this session let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs) let _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes let _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes let _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) let _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) let queueSortState = { key: 'filename', direction: 'asc' }; // History state let historyRowsData = []; let historySortState = { key: 'date', direction: 'desc' }; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; let recentSortState = { key: 'date', direction: 'desc' }; let selectedRecentIds = new Set(); // --- Init --- async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); restoreQueueStateFromConfig(); renderHosterSummary(); renderHosterModal(); renderSettings(); renderAccounts(); setupListeners(); setupDragDrop(); loadHistory(); renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); // Version display try { const version = await window.api.getVersion(); const versionLabel = document.getElementById('versionLabel'); if (versionLabel) versionLabel.textContent = `v${version}`; } catch {} // Update listeners window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); // Upload event listeners — with debug logging to file window.api.onUploadProgress((data) => { window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || '')); handleProgress(data); }); window.api.onUploadBatchDone((data) => { window.api.debugLog('RX upload-batch-done'); handleBatchDone(data); }); window.api.onUploadStats((data) => { window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs); handleStats(data); }); window.api.onShutdownCountdown(handleShutdownCountdown); // Folder monitor: auto-queue new files window.api.onFolderMonitorNewFiles((files) => { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const fm = config.globalSettings && config.globalSettings.folderMonitor; const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; if (fmHosters.length > 0) { // Pre-selected hosters: set them as active selection and add directly to queue selectedUploadHosters = fmHosters.slice(); const newFiles = []; for (const p of files) { if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } } if (newFiles.length > 0) { selectedFiles.push(...newFiles); buildQueuePreview(); updateUploadView(); if (fm.autoStart && !uploading && !healthCheckRunning) startUpload(); } } else { // No pre-selected hosters: open modal addPathsToQueue(files); } }); // Account switched notification window.api.onAccountSwitched((data) => { window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`); }); // Drop target window: files dropped on the small floating window window.api.onDropTargetFiles((paths) => { addPathsToQueue(paths); }); window.api.debugLog('init complete, all listeners registered'); // Restore always-on-top state try { const onTop = await window.api.getAlwaysOnTop(); alwaysOnTopState = !!onTop; } catch {} scheduleStartupAccountCheck(); } // --- Tab switching --- document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); tab.classList.add('active'); document.getElementById(`${tab.dataset.view}-view`).classList.add('active'); if (tab.dataset.view === 'history') loadHistory(); }); }); // --- Hoster selection --- function accountHasCreds(name, account) { if (!account) return false; if (account.authType === 'api') return !!account.apiKey; if (account.authType === 'login') return !!(account.username && account.password); // Fallback if (name === 'vidmoly.me') return !!(account.username && account.password); if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; return !!account.apiKey; } // Returns hosters that have at least one enabled account with credentials function getAvailableHosters() { const result = []; for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); if (hasEnabledAccount) result.push({ name }); } return result; } function syncSelectedUploadHosters() { const available = new Set(getAvailableHosters().map(item => item.name)); selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); if (selectedUploadHosters.length === 0) { selectedUploadHosters = HOSTERS.filter(name => { const accounts = config.hosters[name]; return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); }); } } function getSelectedHosters() { return selectedUploadHosters.slice(); } function getHosterLabel(name) { const labels = { 'doodstream.com': 'Doodstream', 'voe.sx': 'VOE', 'vidmoly.me': 'Vidmoly', 'byse.sx': 'Byse' }; return labels[name] || name; } function getAccountAuthLabel(account) { if (!account) return ''; if (account.authType === 'api') return 'API'; if (account.authType === 'login') return 'Web Login'; return ''; } function getAccountDisplayName(name, account) { const authLabel = getAccountAuthLabel(account); return authLabel ? `${getHosterLabel(name)} (${authLabel})` : getHosterLabel(name); } function maskCredential(value, keep = 4) { const text = String(value || '').trim(); if (!text) return ''; if (text.length <= keep) return text; return `${text.slice(0, keep)}…${text.slice(-2)}`; } function ensureAccountStatusEntries() { const nextStatuses = {}; for (const { account } of getAllAccountsFlat()) { if (account.id) { nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; } } accountStatuses = nextStatuses; } // Returns flat array of all accounts: [{ name, account, index }] function getAllAccountsFlat() { const result = []; for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; accounts.forEach((account, index) => result.push({ name, account, index })); } return result; } // Returns flat array of accounts with credentials function getAccountsWithCredsFlat() { return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account)); } // Find account by ID across all hosters function findAccountById(accountId) { for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; const account = accounts.find(a => a.id === accountId); if (account) return { name, account }; } return null; } function scheduleStartupAccountCheck() { const accounts = getAccountsWithCredsFlat(); if (!accounts.length) return; setTimeout(() => { runHealthCheck('startup').catch(() => {}); }, 500); } function renderHosterSummary() { const summary = document.getElementById('hosterSummary'); if (!summary) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { summary.textContent = 'Keine Upload-Ziele ausgewählt'; } else if (hosters.length === 1) { summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`; } else { summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; } } function renderHosterModal() { const list = document.getElementById('hosterModalList'); const hint = document.getElementById('hosterModalHint'); if (!list || !hint) return; const available = getAvailableHosters(); if (available.length === 0) { list.innerHTML = ''; hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.'; return; } list.innerHTML = available.map(item => { const checked = selectedUploadHosters.includes(item.name); // Get first enabled account's status for subtitle const accounts = config.hosters[item.name] || []; const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a)); const accountCount = enabledAccounts.length; let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`; // Check if any account has ok status const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok'); const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error'); if (hasOk) subtitle += ' • Bereit'; else if (hasError) subtitle += ' • Fehler'; return ` `; }).join(''); hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.'; list.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.addEventListener('change', () => { input.closest('.hoster-option')?.classList.toggle('selected', input.checked); }); }); } function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); document.getElementById('hosterModal').style.display = 'flex'; } function closeHosterModal() { const modal = document.getElementById('hosterModal'); if (modal) modal.style.display = 'none'; } function applyHosterSelection() { selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); // Move pending files to selectedFiles on confirm if (_pendingFiles.length > 0) { selectedFiles.push(..._pendingFiles); _pendingFiles = []; } renderHosterSummary(); updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files document.getElementById('hosterModal').style.display = 'none'; } function cancelHosterModal() { _pendingFiles = []; closeHosterModal(); } function normalizeRestoredJobStatus(status) { if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status; return 'queued'; } function restoreQueueStateFromConfig() { if (config?.globalSettings?.resumeQueueOnLaunch === false) return; const pending = config?.globalSettings?.pendingQueue; if (!pending || typeof pending !== 'object') return; selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters.filter(Boolean) : selectedUploadHosters; selectedFiles = Array.isArray(pending.selectedFiles) ? pending.selectedFiles .filter(file => file && file.path) .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; const rawJobs = Array.isArray(pending.queueJobs) ? pending.queueJobs .filter(job => job && job.fileName && job.hoster) .map(job => ({ id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: null, file: job.file || '', fileName: job.fileName, hoster: job.hoster, status: normalizeRestoredJobStatus(job.status), bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, bytesTotal: job.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: job.error || null, result: job.result || null, attempt: 0, maxAttempts: job.maxAttempts || 0, link: '', progress: job.status === 'done' ? 1 : 0 })) : []; // Deduplicate: keep the job with the best status for each file+hoster pair const seen = new Map(); const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 }; for (const job of rawJobs) { const key = `${job.file}|${job.hoster}`; const existing = seen.get(key); if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) { seen.set(key, job); } } queueJobs = Array.from(seen.values()); } function buildPersistedQueueState() { const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { if (job.file && !selectedFileMap.has(job.file)) { selectedFileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); } } if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { return null; } return { selectedUploadHosters: getSelectedHosters(), selectedFiles: Array.from(selectedFileMap.values()), queueJobs: queueJobs.map(job => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster, // Save aborted jobs as queued so they survive restart status: job.status === 'aborted' ? 'queued' : job.status, bytesTotal: job.bytesTotal || 0, error: job.status === 'aborted' ? null : (job.error || null), result: job.result || null, maxAttempts: job.maxAttempts || 0 })) }; } async function persistQueueStateNow() { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: buildPersistedQueueState() }; config.globalSettings = globalSettings; await window.api.saveGlobalSettings(globalSettings); } function persistQueueStateSoon(immediate) { clearTimeout(queuePersistTimer); if (immediate) { persistQueueStateNow().catch(() => {}); return; } // Use longer debounce during uploads to reduce disk I/O const delay = uploading ? 10000 : 500; queuePersistTimer = setTimeout(() => { persistQueueStateNow().catch(() => {}); }, delay); } function clearPersistedQueueStateSoon() { clearTimeout(queuePersistTimer); queuePersistTimer = setTimeout(() => { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: null }; config.globalSettings = globalSettings; window.api.saveGlobalSettings(globalSettings).catch(() => {}); }, 0); } // --- File selection --- function setupDragDrop() { const dropZone = document.getElementById('dropZone'); // Allow drop on the entire upload view const uploadView = document.getElementById('upload-view'); let _dragCounter = 0; dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over'); addDroppedFiles(e.dataTransfer.files).catch(console.error); }); dropZone.addEventListener('click', () => pickFiles()); // Also handle drops on queue container uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); uploadView.addEventListener('drop', (e) => { e.preventDefault(); if (e.target.closest('.drop-zone')) return; // handled above addDroppedFiles(e.dataTransfer.files).catch(console.error); }); } let _pendingFiles = []; // Files waiting for hoster modal confirmation let _addingDropped = false; async function addDroppedFiles(fileList) { if (_addingDropped) return; _addingDropped = true; try { const files = Array.from(fileList); const existingPaths = new Set([ ...selectedFiles.map(f => f.path), ..._pendingFiles.map(f => f.path) ]); const newFiles = []; for (const file of files) { let filePath = ''; try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } if (!filePath) continue; // Detect folders: directories report size 0 and empty type in Electron drag-and-drop if (file.type === '' && file.size === 0) { try { const folderFiles = await window.api.resolveFolderFiles(filePath); if (folderFiles && folderFiles.length > 0) { for (const fp of folderFiles) { if (!existingPaths.has(fp)) { const name = fp.split('\\').pop().split('/').pop(); newFiles.push({ path: fp, name, size: null }); existingPaths.add(fp); } } continue; } } catch {} } // Regular file const fileName = file.name || ''; if (!existingPaths.has(filePath)) { newFiles.push({ path: filePath, name: fileName, size: file.size }); existingPaths.add(filePath); } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } finally { _addingDropped = false; } } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; addPathsToQueue(paths); } async function pickFolder() { const paths = await window.api.selectFolder(); if (!paths) return; addPathsToQueue(paths); } function addPathsToQueue(paths) { const newFiles = []; for (const p of paths) { if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } function updateUploadView() { const dropZone = document.getElementById('dropZone'); const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); if (selectedFiles.length === 0 && queueJobs.length === 0) { dropZone.style.display = 'flex'; queueShell.style.display = 'none'; queueActions.style.display = 'none'; } else { dropZone.style.display = 'none'; queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; if (!uploading && selectedFiles.length > 0) { buildQueuePreview(); } } updateQueueActionButtons(); } function updateStartButton() { const btn = document.getElementById('startUploadBtn'); const hosters = getSelectedHosters(); const hasFiles = queueJobs.some(j => j.status === 'queued' || j.status === 'preview'); btn.disabled = uploading || hosters.length === 0 || !hasFiles; } function updateQueueActionButtons() { updateStartButton(); const hasSelection = selectedJobIds.size > 0; const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued'].includes(job.status)); const hasMovableSelection = hasSelection && !uploading; const startSelectedBtn = document.getElementById('startSelectedBtn'); const reuploadBtn = document.getElementById('reuploadSelectedBtn'); const abortSelectedBtn = document.getElementById('abortSelectedBtn'); const finishStopBtn = document.getElementById('finishStopBtn'); const abortAllBtn = document.getElementById('abortAllBtn'); const moveTopBtn = document.getElementById('moveTopBtn'); const moveUpBtn = document.getElementById('moveUpBtn'); const moveDownBtn = document.getElementById('moveDownBtn'); const moveBottomBtn = document.getElementById('moveBottomBtn'); if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection || getSelectedHosters().length === 0; if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; if (finishStopBtn) finishStopBtn.disabled = !uploading; if (abortAllBtn) abortAllBtn.disabled = !uploading; if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection; if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; } // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); if (hosters.length === 0) { queueJobs = queueJobs.filter(j => j.status !== 'preview'); rebuildJobIndex(); renderQueueTable(); persistQueueStateSoon(); return; } // Remove old preview jobs queueJobs = queueJobs.filter(j => j.status !== 'preview'); // Build a Set for fast existence checks const existingKeys = new Set(); for (const j of queueJobs) { if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`); } for (const file of selectedFiles) { for (const hoster of hosters) { const key = `${file.path}|${hoster}`; if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) { const job = { id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: file.path, fileName: file.name, hoster, status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: null, result: null, attempt: 0, maxAttempts: 0, link: '' }; queueJobs.push(job); existingKeys.add(key); } } } rebuildJobIndex(); renderQueueTable(); persistQueueStateSoon(); } // --- Job Index Management --- function rebuildJobIndex() { _jobIndexById.clear(); _jobIndexByUploadId.clear(); for (const job of queueJobs) { _jobIndexById.set(job.id, job); if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); } } function indexJob(job) { _jobIndexById.set(job.id, job); if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); } function removeJobFromIndex(job) { _jobIndexById.delete(job.id); if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); // Track deletion so handleProgress() won't re-create this job from stale callbacks _deletedJobIds.add(job.id); if (job.uploadId) _deletedJobIds.add(job.uploadId); } // --- Queue Table Rendering (debounced with virtual scrolling) --- let _renderQueued = false; let _sortedJobsCache = []; const VIRTUAL_ROW_HEIGHT = 28; const VIRTUAL_OVERSCAN = 10; let _lastVisibleRange = { start: -1, end: -1 }; let _queueListenersBound = false; // Throttled UI update scheduling – max one render per 200ms during uploads let _uiUpdateTimer = null; const UI_UPDATE_INTERVAL = 200; // ms function scheduleQueueRender() { if (_renderQueued) return; _renderQueued = true; requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); } function scheduleThrottledUIUpdate() { if (_uiUpdateTimer) return; _uiUpdateTimer = setTimeout(() => { _uiUpdateTimer = null; scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); }, UI_UPDATE_INTERVAL); } function buildRowHtml(job) { const statusClass = `status-${job.status}`; const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; const uploadedSize = job.status === 'preview' ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; const statusText = getStatusText(job); const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; return ` ${escapeHtml(job.fileName)} ${uploadedSize} ${escapeHtml(job.hoster)} ${statusText} ${elapsed} ${remaining} ${speed}
${job.status === 'preview' ? '' : pct + '%'}
`; } // In-place update of a single row's cells (avoids full innerHTML rebuild) function _updateRowInPlace(tr, job) { const statusClass = `status-${job.status}`; const uploadedSize = job.status === 'preview' ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; const statusText = getStatusText(job); const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; // Update row class tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; tr.dataset.link = link; const cells = tr.children; if (cells.length < 8) return false; // structure mismatch, needs full rebuild cells[1].textContent = uploadedSize; // cells[0] (filename) and cells[2] (hoster) don't change during upload const badge = cells[3].querySelector('.status-badge'); if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; } cells[4].textContent = elapsed; cells[5].textContent = remaining; cells[6].textContent = speed; const fill = cells[7].querySelector('.progress-bar-fill'); if (fill) { fill.style.width = pct + '%'; fill.className = `progress-bar-fill ${statusClass}`; } const pctSpan = cells[7].querySelector('.progress-pct'); if (pctSpan) pctSpan.textContent = job.status === 'preview' ? '' : pct + '%'; return true; } function renderQueueTable() { const tbody = document.getElementById('queueBody'); if (!tbody) return; _sortedJobsCache = sortQueueJobs(queueJobs); const totalRows = _sortedJobsCache.length; if (totalRows < 200) { // Try in-place update if row count matches (fast path) const existingRows = tbody.querySelectorAll('.queue-row'); if (existingRows.length === totalRows && totalRows > 0) { // In-place update – no DOM destruction for (let i = 0; i < totalRows; i++) { const tr = existingRows[i]; const job = _sortedJobsCache[i]; // If row identity changed (different job), fall back to full rebuild if (tr.dataset.jobId !== job.id) { tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; break; } _updateRowInPlace(tr, job); } } else { // Full rebuild needed (row count changed) tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; } } else { // Virtual scrolling for large queues — in-place update when range unchanged _renderVirtualRows(tbody); } // Bind event delegation once if (!_queueListenersBound) { _queueListenersBound = true; tbody.addEventListener('click', (e) => { const row = e.target.closest('.queue-row'); if (row) handleRowClick(e, row); }); tbody.addEventListener('contextmenu', (e) => { const row = e.target.closest('.queue-row'); if (row) handleRowContextMenu(e, row); }); } // Update retry button visibility const hasFailedJobs = queueJobs.some(j => j.status === 'error'); document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; updateQueueActionButtons(); } function _renderVirtualRows(tbody) { const scrollContainer = document.getElementById('queueContainer'); if (!scrollContainer) return; const totalRows = _sortedJobsCache.length; const scrollTop = scrollContainer.scrollTop; // Use a minimum viewport height to avoid rendering nothing when container is being laid out const viewportHeight = Math.max(scrollContainer.clientHeight, 200); const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN); const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN); // Same range — try in-place update to avoid hover flicker if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) { const rows = tbody.querySelectorAll('.queue-row'); if (rows.length === endIdx - startIdx) { let allMatch = true; for (let i = 0; i < rows.length; i++) { const job = _sortedJobsCache[startIdx + i]; if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; } _updateRowInPlace(rows[i], job); } if (allMatch) return; // all rows updated in-place, no rebuild needed } } _lastVisibleRange = { start: startIdx, end: endIdx }; const topPad = startIdx * VIRTUAL_ROW_HEIGHT; const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT); let html = ''; if (topPad > 0) html += ``; for (let i = startIdx; i < endIdx; i++) { html += buildRowHtml(_sortedJobsCache[i]); } if (bottomPad > 0) html += ``; tbody.innerHTML = html; } function _onQueueScroll() { if (_sortedJobsCache.length >= 200) { const tbody = document.getElementById('queueBody'); if (tbody) _renderVirtualRows(tbody); } } const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); const _collatorSimple = new Intl.Collator('de'); function sortQueueJobs(jobs) { const { key, direction } = queueSortState; const factor = direction === 'asc' ? 1 : -1; return jobs.slice().sort((a, b) => { let cmp = 0; if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName); else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster); else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status); else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0); else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0); return cmp * factor; }); } function getStatusOrder(status) { const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 }; return order[status] ?? 4; } function getStatusText(job) { switch (job.status) { case 'preview': return 'Bereit'; case 'queued': return 'Wartet'; case 'getting-server': return 'Server...'; case 'uploading': return 'Upload'; case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; case 'done': return 'Fertig'; case 'aborted': return 'Abgebrochen'; case 'error': return 'Fehlgeschlagen'; case 'skipped': return 'Übersprungen'; default: return job.status; } } // --- Queue interactions --- function handleRowClick(e, row) { const jobId = row.dataset.jobId; if (e.ctrlKey || e.metaKey) { if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); else selectedJobIds.add(jobId); } else if (e.shiftKey && selectedJobIds.size > 0) { // Use sorted jobs cache for correct shift-click with virtual scrolling const sortedIds = _sortedJobsCache.map(j => j.id); const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id)); const curIdx = sortedIds.indexOf(jobId); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]); } } else { selectedJobIds.clear(); selectedJobIds.add(jobId); // Single click on done job -> copy link const job = queueJobs.find(j => j.id === jobId); if (job && job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } } } renderQueueTable(); } // --- Context menu --- let alwaysOnTopState = false; function handleRowContextMenu(e, row) { e.preventDefault(); const jobId = row.dataset.jobId; if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); renderQueueTable(); } showContextMenu(e.clientX, e.clientY); } function showContextMenu(x, y) { const menu = document.getElementById('contextMenu'); // Update "Always on top" text const aotItem = menu.querySelector('[data-action="always-on-top"]'); if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund'; // Update labels with selection count const n = selectedJobIds.size; const delItem = menu.querySelector('[data-action="delete-selected"]'); if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; const copyItem = menu.querySelector('[data-action="copy-links"]'); if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; }); const startItem = menu.querySelector('[data-action="start-selected"]'); if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten'; // Dynamic "cancel hoster" items const cancelSep = menu.querySelector('.ctx-hoster-cancel-sep'); const cancelContainer = menu.querySelector('.ctx-hoster-cancel-items'); const activeHosters = [...new Set(queueJobs.filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview').map(j => j.hoster))]; cancelContainer.innerHTML = ''; if (activeHosters.length > 0) { cancelSep.style.display = ''; activeHosters.forEach(h => { const item = document.createElement('div'); item.className = 'ctx-item ctx-item-danger'; item.dataset.action = `cancel-hoster:${h}`; item.textContent = `${h} abbrechen`; cancelContainer.appendChild(item); }); } else { cancelSep.style.display = 'none'; } // Dynamic "delete by hoster" submenu const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu'); const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items'); const hosterCounts = new Map(); queueJobs.forEach(j => hosterCounts.set(j.hoster, (hosterCounts.get(j.hoster) || 0) + 1)); deleteHosterContainer.innerHTML = ''; if (hosterCounts.size > 0) { deleteHosterSubmenu.style.display = ''; hosterCounts.forEach((count, hoster) => { const item = document.createElement('div'); item.className = 'ctx-item ctx-item-danger'; item.dataset.action = `delete-hoster:${hoster}`; item.textContent = `${getHosterLabel(hoster)} (${count})`; deleteHosterContainer.appendChild(item); }); } else { deleteHosterSubmenu.style.display = 'none'; } menu.style.display = 'block'; const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5); menu.style.left = menuX + 'px'; menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; // Flip submenus if they would overflow the viewport right edge menu.querySelectorAll('.ctx-submenu-items').forEach(sub => { // Temporarily show to measure actual width (display:none → offsetWidth=0) sub.style.visibility = 'hidden'; sub.style.display = 'block'; sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth); sub.style.display = ''; sub.style.visibility = ''; }); } function hideContextMenu() { document.getElementById('contextMenu').style.display = 'none'; document.getElementById('recentContextMenu').style.display = 'none'; } function deleteSelectedRecentFiles() { if (selectedRecentIds.size === 0) return; sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order)); selectedRecentIds.clear(); renderRecentUploadsPanel(); } function copySelectedRecentLinks() { const links = sessionFilesData .filter(r => selectedRecentIds.has(r.order) && !r.isError) .map(r => r.link) .filter(Boolean); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Backup modal --- let _backupMode = null; // 'export' | 'import' function openBackupModal(mode) { _backupMode = mode; const modal = document.getElementById('backupPasswordModal'); const title = document.getElementById('backupModalTitle'); const confirmRow = document.getElementById('backupConfirmRow'); const status = document.getElementById('backupModalStatus'); document.getElementById('backupPassword').value = ''; document.getElementById('backupPasswordConfirm').value = ''; status.textContent = ''; if (mode === 'export') { title.textContent = 'Backup verschlüsseln'; confirmRow.style.display = ''; document.getElementById('confirmBackupBtn').textContent = 'Exportieren'; } else { title.textContent = 'Backup entschlüsseln'; confirmRow.style.display = 'none'; document.getElementById('confirmBackupBtn').textContent = 'Importieren'; } modal.style.display = 'flex'; document.getElementById('backupPassword').focus(); } function closeBackupModal() { document.getElementById('backupPasswordModal').style.display = 'none'; _backupMode = null; } async function confirmBackupAction() { const pw = document.getElementById('backupPassword').value; const status = document.getElementById('backupModalStatus'); if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; } if (_backupMode === 'export') { const pw2 = document.getElementById('backupPasswordConfirm').value; if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; } status.textContent = 'Exportiere...'; try { const result = await window.api.exportBackup(pw); if (result.canceled) { status.textContent = ''; return; } if (result.ok) { closeBackupModal(); showCopyToast('Backup exportiert'); } } catch (err) { status.textContent = err.message || 'Export fehlgeschlagen'; } } else { status.textContent = 'Importiere...'; try { const result = await window.api.importBackup(pw); if (result.canceled) { status.textContent = ''; return; } if (result.ok) { config = result.config; hosterSettings = config.hosterSettings || {}; // Refresh global settings state (always-on-top, etc.) alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); window.api.setAlwaysOnTop(alwaysOnTopState); closeBackupModal(); renderSettings(); renderAccounts(); renderHosterSummary(); renderHosterModal(); loadHistory(); showCopyToast('Backup importiert'); } } catch (err) { status.textContent = err.message || 'Import fehlgeschlagen'; } } } document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { hideContextMenu(); cancelHosterModal(); } if (e.target.closest('input, textarea, select')) return; const activeView = document.querySelector('.view.active'); // Ctrl+A if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); // If recent files panel is focused / has selection, select all recent files if (selectedRecentIds.size > 0 || document.activeElement?.closest('.recent-files-panel')) { sessionFilesData.forEach(r => selectedRecentIds.add(r.order)); renderRecentUploadsPanel(); } else if (queueJobs.length > 0) { queueJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); } } } // Delete if (e.key === 'Delete') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); if (selectedRecentIds.size > 0) { deleteSelectedRecentFiles(); } else if (selectedJobIds.size > 0) { const deletedIds = [...selectedJobIds]; // Cancel active uploads for deleted jobs const activeIds = deletedIds.filter(id => { const j = _jobIndexById.get(id); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(true); } } } }); document.getElementById('contextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; const action = item.dataset.action; if (!action) return; hideContextMenu(); handleContextAction(action); }); async function handleContextAction(action) { if (action === 'start-selected') { startSelectedUpload(); } else if (action === 'copy-links') { const links = getSelectedJobLinks(); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'delete-selected') { // Cancel active uploads for deleted jobs const activeIds = [...selectedJobIds].filter(id => { const j = _jobIndexById.get(id); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(true); } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { // Cancel all active uploads const activeIds = queueJobs .filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') .map(j => j.id); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); selectedFiles = []; syncSelectedFilesFromQueue(); renderQueueTable(); updateUploadView(); updateStatusBar(); persistQueueStateSoon(true); } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; await window.api.setAlwaysOnTop(alwaysOnTopState); } else if (action.startsWith('cancel-hoster:')) { const hoster = action.replace('cancel-hoster:', ''); const jobIds = []; for (const job of queueJobs) { if (job.hoster === hoster && (job.status === 'uploading' || job.status === 'queued' || job.status === 'retrying' || job.status === 'getting-server' || job.status === 'preview')) { jobIds.push(job.id); // Mark queued/preview jobs as error immediately if (job.status === 'queued' || job.status === 'preview') { job.status = 'error'; job.error = 'Hoster abgebrochen'; } } } // Cancel active uploads via IPC if (jobIds.length > 0) await window.api.cancelSelectedJobs(jobIds); renderQueueTable(); updateStatusBar(); updateQueueActionButtons(); } else if (action.startsWith('delete-hoster:')) { const hoster = action.replace('delete-hoster:', ''); // Cancel active uploads for this hoster const activeIds = queueJobs .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) .map(j => j.id); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); // Remove ALL jobs for this hoster queueJobs = queueJobs.filter(j => { if (j.hoster === hoster) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); updateQueueActionButtons(); persistQueueStateSoon(true); } else if (action.startsWith('shutdown-')) { const mode = action.replace('shutdown-', ''); await window.api.setShutdownAfterFinish(mode); } } function getSelectedJobLinks() { return queueJobs .filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); } // --- Upload --- async function startUpload() { if (uploading) return; // Wait for any running health check to finish (e.g. startup auto-check) while (healthCheckRunning) await new Promise(r => setTimeout(r, 100)); uploading = true; // set immediately to prevent double-click race updateQueueActionButtons(); const hosters = getSelectedHosters(); if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); uploading = false; updateQueueActionButtons(); return; } if (queueJobs.length === 0 && selectedFiles.length > 0) buildQueuePreview(); const jobsToStart = queueJobs.filter((job) => job.status === 'preview' || job.status === 'queued'); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } // Auto health check — only check hosters that have jobs to start if (autoHealthCheckEnabled) { const jobHosters = new Set(jobsToStart.map(j => j.hoster)); const checkHosters = [...jobHosters].filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx'); if (checkHosters.length > 0) { healthCheckRunning = true; try { const rows = await executeHealthCheck(checkHosters, 'auto'); const errors = rows.filter(r => r.status === 'error'); if (errors.length > 0) { alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); uploading = false; updateQueueActionButtons(); return; } } catch (err) { alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); uploading = false; updateQueueActionButtons(); return; } finally { healthCheckRunning = false; } } } try { queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); alert(`Upload-Start fehlgeschlagen: ${err.message}`); } } async function startSelectedUpload() { if (uploading) return; while (healthCheckRunning) await new Promise(r => setTimeout(r, 100)); uploading = true; // set immediately to prevent double-click race updateQueueActionButtons(); const hosters = getSelectedHosters(); if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); uploading = false; updateQueueActionButtons(); return; } const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && (job.status === 'preview' || job.status === 'queued')); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } // Auto health check — only check hosters that have jobs to start if (autoHealthCheckEnabled) { const jobHosters = new Set(jobsToStart.map(j => j.hoster)); const checkHosters = [...jobHosters].filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx'); if (checkHosters.length > 0) { healthCheckRunning = true; try { const rows = await executeHealthCheck(checkHosters, 'auto'); const errors = rows.filter(r => r.status === 'error'); if (errors.length > 0) { alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); uploading = false; updateQueueActionButtons(); return; } } catch (err) { alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); uploading = false; updateQueueActionButtons(); return; } finally { healthCheckRunning = false; } } } try { jobsToStart.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); alert(`Upload-Start fehlgeschlagen: ${err.message}`); } } async function cancelUpload() { await window.api.cancelUpload(); uploading = false; // Reset all non-finished jobs back to queued state for (const job of queueJobs) { if (!['done', 'error', 'skipped'].includes(job.status)) { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(); } // --- Progress handling --- function handleProgress(data) { let job = data.jobId ? _jobIndexById.get(data.jobId) : null; if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job) { job = queueJobs.find(j => j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' ) || queueJobs.find(j => j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview' ); if (job && data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } } if (!job) { // Don't re-create jobs that were explicitly deleted by the user if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) { return; } job = { id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: data.uploadId, file: '', fileName: data.fileName, hoster: data.hoster, status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: null, result: null, attempt: 0, maxAttempts: 0, link: '' }; queueJobs.push(job); indexJob(job); } // Don't regress from terminal states (stale callbacks can arrive after completion) if (job.status === 'done' || job.status === 'skipped') return; // Update job state job.status = data.status; job.bytesUploaded = data.bytesUploaded || 0; job.bytesTotal = data.bytesTotal || job.bytesTotal; // Track session total bytes (survives removeFromQueueOnDone) if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) { _sessionTotalBytes += job.bytesTotal; _sessionTrackedJobs.add(job.id); } job.speedKbs = data.speedKbs || 0; job.elapsed = data.elapsed || 0; job.remaining = data.remaining || 0; job.error = data.error || null; job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; job.progress = data.progress || 0; if (data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } maybeAddSessionFile(job); // Track session uploaded bytes (survives removeFromQueueOnDone) if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) { _sessionUploadedBytes += job.bytesTotal || 0; _sessionDoneJobs.add(job.id); } // Track completed uploads so they don't get re-queued after removal if (job.status === 'done') { _completedUploadKeys.add(`${job.file}|${job.hoster}`); } // Remove finished jobs from queue immediately if setting is enabled if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { removeJobFromIndex(job); selectedJobIds.delete(job.id); queueJobs = queueJobs.filter(j => j !== job); } // Status changes (done/error/etc) get immediate render; ongoing progress is throttled if (data.status === 'uploading') { scheduleThrottledUIUpdate(); } else { scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); } persistQueueStateSoon(); } function handleBatchDone(summary) { uploading = false; applySummaryResults(summary); // Reset aborted jobs back to queued so they can be restarted for (const job of queueJobs) { if (job.status === 'aborted') { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } syncSelectedFilesFromQueue(); updateQueueActionButtons(); renderQueueTable(); renderRecentUploadsPanel(); loadHistory(); const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; if (removeOnDone) { const doneJobs = queueJobs.filter(j => j.status === 'done'); for (const job of doneJobs) { removeJobFromIndex(job); selectedJobIds.delete(job.id); } queueJobs = queueJobs.filter(j => j.status !== 'done'); renderQueueTable(); } if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); else clearPersistedQueueStateSoon(); lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); } function handleStats(data) { lastUploadStats = { state: data.state || 'idle', globalSpeedKbs: data.globalSpeedKbs || 0, totalBytes: data.totalBytes || 0, elapsed: data.elapsed || 0, activeJobs: data.activeJobs || 0 }; updateStatusBar(); updateStatsPanel(); // Track run time if (data.state === 'uploading' || data.state === 'stopping') { if (!statsStartTime) { statsStartTime = Date.now(); statsRunTimer = setInterval(() => { const el = document.getElementById('statRunTime'); if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000)); }, 1000); } } else if (data.state === 'idle' && statsRunTimer) { clearInterval(statsRunTimer); statsRunTimer = null; } } // --- Retry --- async function retrySelectedJobs() { const retryJobs = []; queueJobs.forEach(j => { if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { j.status = uploading ? 'queued' : 'preview'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.elapsed = 0; j.remaining = 0; j.progress = 0; j.uploadId = null; retryJobs.push(j); if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); } } }); if (retryJobs.length === 0) return; // Select the retry jobs and start them immediately selectedJobIds.clear(); retryJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(); await startSelectedUpload(); } async function abortSelectedJobs() { const activeJobIds = []; queueJobs.forEach((job) => { if (!selectedJobIds.has(job.id)) return; if (['preview', 'queued'].includes(job.status)) { job.status = 'aborted'; job.error = 'Abgebrochen'; job.progress = 0; job.uploadId = null; } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { activeJobIds.push(job.id); } }); if (activeJobIds.length > 0) { await window.api.cancelSelectedJobs(activeJobIds); } selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(true); } async function finishUploadsInProgress() { if (!uploading) return; await window.api.finishAfterActive(); lastUploadStats.state = 'stopping'; updateStatusBar(); } async function abortAllUploads() { await cancelUpload(); } function moveSelectedJobs(direction) { if (uploading || selectedJobIds.size === 0) return; const jobs = queueJobs.slice(); if (direction === 'top') { queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id))); } else if (direction === 'bottom') { queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id))); } else if (direction === 'up') { for (let i = 1; i < jobs.length; i++) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) { [jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]]; } } queueJobs = jobs; } else if (direction === 'down') { for (let i = jobs.length - 2; i >= 0; i--) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) { [jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]]; } } queueJobs = jobs; } rebuildJobIndex(); renderQueueTable(); updateStatusBar(); persistQueueStateSoon(true); } function syncSelectedFilesFromQueue() { const fileMap = new Map(); queueJobs .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) .forEach((job) => { if (!job.file || fileMap.has(job.file)) return; fileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); }); selectedFiles = Array.from(fileMap.values()); } function maybeAddSessionFile(job) { if (!job) return; const dt = formatDateTime(new Date()); if (job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (!link) return; if (!sessionFilesData.some((row) => row.link === link && row.filename === job.fileName && row.host === job.hoster)) { sessionFilesData.push({ date: dt.text, dateTs: dt.ts, filename: job.fileName || '', host: job.hoster || '', link, isError: false, order: sessionFilesData.length }); renderRecentUploadsPanel(); } } } function applySummaryResults(summary) { const files = Array.isArray(summary?.files) ? summary.files : []; for (const file of files) { for (const result of file.results || []) { const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster); if (!job) continue; if (result.status === 'done') { job.status = 'done'; job.result = { download_url: result.download_url || null, embed_url: result.embed_url || null, file_code: result.file_code || null }; job.error = null; job.progress = 1; job.bytesUploaded = job.bytesTotal || file.size || 0; } else if (result.status === 'aborted') { job.status = 'aborted'; job.error = result.error || 'Abgebrochen'; } else if (result.status === 'error') { job.status = 'error'; job.error = result.error || 'Fehlgeschlagen'; } maybeAddSessionFile(job); } } } // Single-pass queue stats computation (shared by status bar + stats panel) function _computeQueueStats() { let remaining = 0, inProgress = 0, done = 0, errors = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0; const total = queueJobs.length; for (let i = 0; i < total; i++) { const job = queueJobs[i]; const s = job.status; const bt = job.bytesTotal || 0; const bu = job.bytesUploaded || 0; totalSize += bt; if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { inProgress++; remaining++; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'preview' || s === 'queued') { remaining++; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'done') { done++; } else if (s === 'error') { errors++; } else if (s !== 'skipped') { remainingSize += Math.max(0, bt - bu); } } return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize }; } function updateStatusBar() { const stats = _computeQueueStats(); const etaSeconds = lastUploadStats.globalSpeedKbs > 0 ? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) : 0; const stateText = lastUploadStats.state === 'uploading' ? 'Upload läuft...' : lastUploadStats.state === 'stopping' ? 'Stoppt nach aktiven Uploads...' : uploading ? 'Upload vorbereitet...' : 'Bereit'; document.getElementById('sbState').textContent = stateText; document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); // Session-based bytes: survive removeFromQueueOnDone // Uploaded = done jobs (session) + in-progress bytes still in queue let inProgressBytes = 0; for (const job of queueJobs) { if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') { inProgressBytes += job.bytesUploaded || 0; } } const uploadedSize = _sessionUploadedBytes + inProgressBytes; const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; const sessionDone = sessionFilesData.filter(r => !r.isError).length; const sessionErrors = sessionFilesData.filter(r => r.isError).length; document.getElementById('sbDoneCount').textContent = `Done: ${sessionDone}`; document.getElementById('sbErrorCount').textContent = `Error: ${sessionErrors}`; } // --- Health Check --- function setHealthCheckStatus(text) { // Minimal inline status } function renderHealthCheckResults(results) { const container = document.getElementById('healthCheckResults'); if (!container) return; if (!results || results.length === 0) { container.innerHTML = ''; return; } container.innerHTML = results.map(item => { const status = item.status || 'skipped'; return `
${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')} [${status.toUpperCase()}] ${escapeHtml(item.message || '')}
`; }).join(''); } async function executeHealthCheck(hosters, mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; rows.forEach((row) => { if (!row) return; const key = row.accountId || row.hoster; if (key) { accountStatuses[key] = { status: row.status || 'unchecked', message: row.message || '' }; } }); renderHealthCheckResults(rows); renderAccounts(); renderHosterModal(); return rows; } async function runHealthCheck(mode = 'manual', requestedHosters = null) { if (healthCheckRunning || (uploading && mode === 'manual')) return []; // Build check list: all enabled accounts with creds let hosters; if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { hosters = requestedHosters; } else { hosters = getAccountsWithCredsFlat() .filter(({ account }) => account.enabled !== false) .map(({ name, account }) => ({ hoster: name, accountId: account.id })); } if (hosters.length === 0) { if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); return []; } healthCheckRunning = true; // Mark all accounts as checking for (const h of hosters) { const key = typeof h === 'string' ? h : (h.accountId || h.hoster); accountStatuses[key] = { status: 'checking', message: '' }; } renderAccounts(); try { return await executeHealthCheck(hosters, mode); } catch (err) { renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); return []; } finally { healthCheckRunning = false; renderAccounts(); } } // --- Settings --- function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; const globalSettings = config.globalSettings || {}; const configuredAccounts = getAvailableHosters(); const generalPanel = document.createElement('div'); generalPanel.className = 'hoster-settings-panel'; generalPanel.innerHTML = `
Allgemein System
Uploads
0 = nur pro Hoster
0 = unbegrenzt
Verhalten
Updates
Log
`; container.appendChild(generalPanel); // Toggle general panel generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = generalPanel.querySelector('.hoster-panel-body'); const arrow = generalPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // --- Folder Monitor Panel --- const fm = globalSettings.folderMonitor || {}; const folderMonitorPanel = document.createElement('div'); folderMonitorPanel.className = 'hoster-settings-panel'; folderMonitorPanel.innerHTML = `
Ordnerüberwachung ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}
`; container.appendChild(folderMonitorPanel); // Toggle folder monitor panel folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = folderMonitorPanel.querySelector('.hoster-panel-body'); const arrow = folderMonitorPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // Update badge immediately on checkbox/path change const updateFmBadge = () => { const b = document.getElementById('folderMonitorStatusBadge'); if (!b) return; const enabled = document.getElementById('fmEnabledInput')?.checked; const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim(); if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; } else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; } }; document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge); document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge); document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => { const folder = await window.api.folderMonitorSelectFolder(); if (folder) { document.getElementById('fmFolderPathInput').value = folder; updateFmBadge(); scheduleSettingsSave(); } }); // --- Remote Control Panel --- const remoteSettings = globalSettings.remote || {}; const remotePanel = document.createElement('div'); remotePanel.className = 'hoster-settings-panel'; remotePanel.innerHTML = `
Fernsteuerung ${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}
`; container.appendChild(remotePanel); // Toggle remote panel remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = remotePanel.querySelector('.hoster-panel-body'); const arrow = remotePanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // Copy token document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => { const token = document.getElementById('remoteTokenInput').value; if (token) { await window.api.copyToClipboard(token); document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!'; setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500); } }); // Regenerate token document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => { const newToken = await window.api.remoteGenerateToken(); document.getElementById('remoteTokenInput').value = newToken; scheduleSettingsSave(); }); // Update status window.api.remoteStatus().then(status => { const el = document.getElementById('remoteConnectionStatus'); if (!el) return; if (status.running) { el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; el.style.color = '#94a3b8'; } }).catch(() => {}); // Live client count updates window.api.onRemoteClientCount((count) => { const el = document.getElementById('remoteConnectionStatus'); if (el && el.style.color === 'rgb(16, 185, 129)') { window.api.remoteStatus().then(status => { if (status.running) { el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; } }).catch(() => {}); } }); // --- Backup Panel --- const backupPanel = document.createElement('div'); backupPanel.className = 'hoster-settings-panel'; backupPanel.innerHTML = `
Backup System
`; container.appendChild(backupPanel); backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = backupPanel.querySelector('.hoster-panel-body'); const arrow = backupPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export')); document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import')); // --- Separator before hoster panels --- const separator = document.createElement('div'); separator.style.cssText = 'height:16px'; container.appendChild(separator); if (configuredAccounts.length === 0) { const empty = document.createElement('div'); empty.className = 'settings-empty'; empty.innerHTML = '

Noch keine Account-Einstellungen vorhanden.

Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.'; container.appendChild(empty); } for (const { name } of configuredAccounts) { const hs = hosterSettings[name] || {}; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; const panel = document.createElement('div'); panel.className = 'hoster-settings-panel'; panel.innerHTML = `
${escapeHtml(getHosterLabel(name))} Aktiv
`; container.appendChild(panel); // Toggle panel panel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = panel.querySelector('.hoster-panel-body'); const arrow = panel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); } document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; btn.textContent = 'Prüfe...'; try { const result = await window.api.checkForUpdate(); if (result && result.available) { showUpdateBanner(result); btn.textContent = 'Update gefunden!'; } else { btn.textContent = 'Kein Update verfügbar'; } } catch { btn.textContent = 'Fehler beim Prüfen'; } setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000); }); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(eventName, scheduleSettingsSave); }); } async function chooseLogFilePath() { const folders = await window.api.selectFolder(); if (!folders || !folders[0]) return; const normalized = folders[0].replace(/[\\\/]+$/, ''); document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; scheduleSettingsSave(); } function scheduleSettingsSave() { const feedback = document.getElementById('saveFeedback'); if (feedback) feedback.textContent = 'Speichert...'; clearTimeout(settingsSaveTimer); settingsSaveTimer = setTimeout(() => { saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; }); }, 350); } async function saveSettings(options = {}) { const { feedbackText = 'Gespeichert!' } = options; const newHosterSettings = { ...(config.hosterSettings || {}) }; const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), sessionLog: !!document.getElementById('sessionLogInput')?.checked, resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked, showDropTarget: !!document.getElementById('showDropTargetInput')?.checked, globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), folderMonitor: { enabled: !!document.getElementById('fmEnabledInput')?.checked, folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), recursive: !!document.getElementById('fmRecursiveInput')?.checked, filterMode: document.getElementById('fmFilterModeInput')?.value || 'include', extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(), skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked, delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3), autoStart: !!document.getElementById('fmAutoStartInput')?.checked, hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster) }, remote: { enabled: !!document.getElementById('remoteEnabledInput')?.checked, port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)), token: (document.getElementById('remoteTokenInput')?.value || '').trim(), allowInput: !!document.getElementById('remoteAllowInputInput')?.checked } }; // Always on top setting const aotCheckbox = document.getElementById('alwaysOnTopInput'); if (aotCheckbox) { const newAot = !!aotCheckbox.checked; if (newAot !== alwaysOnTopState) { alwaysOnTopState = newAot; await window.api.setAlwaysOnTop(alwaysOnTopState); } } // Drop target window const dtCheckbox = document.getElementById('showDropTargetInput'); if (dtCheckbox) { if (dtCheckbox.checked) await window.api.showDropTarget(); else await window.api.hideDropTarget(); } for (const name of HOSTERS) { const hs = { ...(hosterSettings[name] || {}) }; document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { const field = input.dataset.hs; if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024)); else hs[field] = parseInt(input.value, 10) || 0; }); newHosterSettings[name] = hs; } await window.api.saveHosterSettings(newHosterSettings); await window.api.saveGlobalSettings(globalSettings); config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; clearTimeout(settingsSaveTimer); // Start/stop folder monitor based on settings const fmSettings = globalSettings.folderMonitor; const badge = document.getElementById('folderMonitorStatusBadge'); if (fmSettings && fmSettings.enabled && fmSettings.folderPath) { try { await window.api.folderMonitorStart(fmSettings); if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; } } catch { if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; } } } else { await window.api.folderMonitorStop(); if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; } } // Start/stop remote server based on settings const remoteSettings = globalSettings.remote; const remoteBadge = document.getElementById('remoteStatusBadge'); if (remoteSettings) { try { await window.api.remoteSaveSettings(remoteSettings); if (remoteBadge) { remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'; remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`; } // Update status display const status = await window.api.remoteStatus(); const statusEl = document.getElementById('remoteConnectionStatus'); if (statusEl) { if (status.running) { statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; statusEl.style.color = '#10b981'; } else { statusEl.textContent = 'Nicht aktiv'; statusEl.style.color = '#94a3b8'; } } } catch {} } const feedback = document.getElementById('saveFeedback'); feedback.textContent = feedbackText; setTimeout(() => { if (feedback.textContent === feedbackText) { feedback.textContent = 'Änderungen werden automatisch gespeichert.'; } }, 1800); } // --- Accounts --- function getCredentialLabel(name, account) { if (!account) return 'Keine Zugangsdaten'; if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`; if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`; // Fallback if (account.username && account.password) return `Login: ${account.username}`; if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`; return 'Keine Zugangsdaten'; } function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; ensureAccountStatusEntries(); const allAccounts = getAllAccountsFlat(); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; if (allAccounts.length === 0) { container.innerHTML = `

Keine Accounts vorhanden

Klicke auf "Account hinzufügen", um einen Hoster einzurichten.
`; return; } // Group by hoster for drag reorder sections const byHoster = {}; for (const { name, account } of allAccounts) { if (!byHoster[name]) byHoster[name] = []; byHoster[name].push(account); } let html = ''; for (const name of HOSTERS) { const accounts = byHoster[name]; if (!accounts || accounts.length === 0) continue; html += `
${escapeHtml(getHosterLabel(name))}
`; accounts.forEach((account, idx) => { const isDisabled = account.enabled === false; const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft'); const statusClass = isDisabled ? 'disabled' : st.status; const credLabel = getCredentialLabel(name, account); const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; html += `
`; }); html += '
'; } container.innerHTML = html; // Wire up buttons container.querySelectorAll('[data-account-toggle]').forEach(btn => { btn.addEventListener('click', () => toggleAccount(btn.dataset.accountToggle)); }); container.querySelectorAll('[data-account-edit]').forEach(btn => { btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit)); }); container.querySelectorAll('[data-account-delete]').forEach(btn => { btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete)); }); container.querySelectorAll('[data-account-check]').forEach(btn => { btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck)); }); // Drag-and-drop reorder within each hoster group setupAccountDragReorder(container); } function setupAccountDragReorder(container) { let draggedCard = null; container.querySelectorAll('.account-card[draggable]').forEach(card => { card.addEventListener('dragstart', (e) => { draggedCard = card; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); card.addEventListener('dragend', () => { if (draggedCard) draggedCard.classList.remove('dragging'); draggedCard = null; container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); }); card.addEventListener('dragover', (e) => { e.preventDefault(); if (!draggedCard || draggedCard === card) return; if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; e.dataTransfer.dropEffect = 'move'; const rect = card.getBoundingClientRect(); const midY = rect.top + rect.height / 2; card.classList.toggle('drag-over-above', e.clientY < midY); card.classList.toggle('drag-over-below', e.clientY >= midY); }); card.addEventListener('dragleave', () => { card.classList.remove('drag-over-above', 'drag-over-below'); }); card.addEventListener('drop', async (e) => { e.preventDefault(); card.classList.remove('drag-over-above', 'drag-over-below'); if (!draggedCard || draggedCard === card) return; const hosterName = card.dataset.accountHoster; if (draggedCard.dataset.accountHoster !== hosterName) return; const draggedId = draggedCard.dataset.accountId; const targetId = card.dataset.accountId; const accounts = config.hosters[hosterName]; if (!Array.isArray(accounts)) return; const fromIdx = accounts.findIndex(a => a.id === draggedId); const toIdx = accounts.findIndex(a => a.id === targetId); if (fromIdx < 0 || toIdx < 0) return; // Move account in array const [moved] = accounts.splice(fromIdx, 1); const rect = card.getBoundingClientRect(); const insertBefore = e.clientY < rect.top + rect.height / 2; const newToIdx = accounts.findIndex(a => a.id === targetId); accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); // Save and re-render await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); renderAccounts(); }); }); } async function toggleAccount(accountId) { const found = findAccountById(accountId); if (!found) return; found.account.enabled = found.account.enabled === false ? true : false; await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); syncSelectedUploadHosters(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); } async function checkSingleAccount(accountId) { if (!accountId || healthCheckRunning) return; const found = findAccountById(accountId); if (!found) return; healthCheckRunning = true; accountStatuses[accountId] = { status: 'checking', message: '' }; renderAccounts(); try { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' }; } catch (err) { accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; } finally { healthCheckRunning = false; } renderAccounts(); } function getCredsFieldsHtml(authType, account) { account = account || {}; if (authType === 'login') { return `
`; } // API key return `
`; } function openAccountModal(editAccountId) { editingAccountId = editAccountId || null; const modal = document.getElementById('accountModal'); const title = document.getElementById('accountModalTitle'); const subtitle = document.getElementById('accountModalSubtitle'); const hosterRow = document.getElementById('accountHosterRow'); const hosterSelect = document.getElementById('accountHosterSelect'); const credsContainer = document.getElementById('accountCredsFields'); const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); statusEl.textContent = ''; statusEl.className = 'account-modal-status'; if (editingAccountId) { // Edit mode const found = findAccountById(editingAccountId); if (!found) return; title.textContent = 'Account bearbeiten'; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; hosterRow.style.display = 'none'; saveBtn.textContent = 'Speichern & prüfen'; credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account); } else { // Add mode — always show all options (multiple accounts per hoster allowed) title.textContent = 'Account hinzufügen'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; hosterRow.style.display = 'flex'; saveBtn.textContent = 'Anlegen & prüfen'; hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => `` ).join(''); const firstOpt = HOSTER_ADD_OPTIONS[0]; credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}); } // Toggle visibility buttons credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); modal.style.display = 'flex'; } function closeAccountModal() { document.getElementById('accountModal').style.display = 'none'; _hideOtpField(); editingAccountId = null; } function openDeleteAccountModal(accountId) { const found = findAccountById(accountId); if (!found) return; const modal = document.getElementById('deleteAccountModal'); const msg = document.getElementById('deleteAccountMessage'); msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`; modal.dataset.accountId = accountId; modal.style.display = 'flex'; } function closeDeleteModal() { document.getElementById('deleteAccountModal').style.display = 'none'; } async function deleteAccount(accountId) { const found = findAccountById(accountId); if (!found) return; // Remove account from the array const accounts = config.hosters[found.name]; if (Array.isArray(accounts)) { config.hosters[found.name] = accounts.filter(a => a.id !== accountId); } delete accountStatuses[accountId]; await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); closeDeleteModal(); } function readAccountCredsFromModal(authType) { if (authType === 'login') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); return { enabled: !!(username && password), authType: 'login', username, password }; } // API const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); return { enabled: !!apiKey, authType: 'api', apiKey }; } async function saveAccount() { let hosterName, authType, accountId; if (editingAccountId) { // Edit existing account const found = findAccountById(editingAccountId); if (!found) return; hosterName = found.name; authType = found.account.authType || 'login'; accountId = editingAccountId; } else { // Add new account const selectValue = document.getElementById('accountHosterSelect')?.value; if (!selectValue) return; const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); if (!opt) return; hosterName = opt.hoster; authType = opt.authType; accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; } const creds = readAccountCredsFromModal(authType); if (!creds.enabled) { const statusEl = document.getElementById('accountModalStatus'); statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.className = 'account-modal-status error'; return; } // Save credentials if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = []; if (editingAccountId) { // Update existing account in array const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId); if (idx >= 0) { config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds }; } } else { // Add new account config.hosters[hosterName].push({ id: accountId, ...creds }); } await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); // Show checking status const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); statusEl.textContent = 'Prüfe Login...'; statusEl.className = 'account-modal-status checking'; saveBtn.disabled = true; accountStatuses[accountId] = { status: 'checking', message: '' }; syncSelectedUploadHosters(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); // Check if OTP was entered (for retry after OTP prompt) const otpInput = document.getElementById('accField_otp'); const otp = otpInput ? otpInput.value.trim() : ''; // Run health check for this specific account (include OTP if provided) const checkPayload = { hoster: hosterName, accountId }; if (otp) checkPayload.otp = otp; try { const result = await window.api.runHealthCheck({ hosters: [checkPayload] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); if (row && row.status === 'otp_required') { // Show OTP input field if not already visible accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' }; statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.className = 'account-modal-status error'; _showOtpField(); saveBtn.textContent = 'OTP bestätigen'; } else if (row && (row.status === 'ok' || row.status === 'warn')) { accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' }; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; statusEl.className = 'account-modal-status ok'; _hideOtpField(); setTimeout(() => closeAccountModal(), 1200); } else { const msg = (row && row.message) || 'Login fehlgeschlagen'; accountStatuses[accountId] = { status: 'error', message: msg }; statusEl.textContent = msg; statusEl.className = 'account-modal-status error'; } } catch (err) { accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; statusEl.className = 'account-modal-status error'; } finally { saveBtn.disabled = false; ensureAccountStatusEntries(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); } } function _showOtpField() { if (document.getElementById('accField_otp')) return; // already visible const container = document.getElementById('accountCredsFields'); const otpHtml = `
`; container.insertAdjacentHTML('beforeend', otpHtml); // Auto-focus the OTP field setTimeout(() => document.getElementById('accField_otp')?.focus(), 50); } function _hideOtpField() { const row = document.getElementById('otpFieldRow'); if (row) row.remove(); } // --- History --- async function loadHistory() { const history = await window.api.getHistory(); const container = document.getElementById('historyContainer'); if (!history || history.length === 0) { historyRowsData = []; container.innerHTML = '

Noch keine Uploads.

'; return; } historySortState = { key: 'date', direction: 'desc' }; historyRowsData = []; let order = 0; for (const batch of history) { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { if (result.status === 'aborted' || result.status === 'error') continue; historyRowsData.push({ date: dt.text, dateTs: dt.ts, filename: file.name || '', host: result.hoster || '', link: result.download_url || result.embed_url || '', isError: false, order: order++ }); } } } renderHistoryTable(container); } function sortRecentFiles(data) { const sorted = data.slice(); const { key, direction } = recentSortState; const dir = direction === 'asc' ? 1 : -1; sorted.sort((a, b) => { if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); if (key === 'filename') return dir * _collatorDE.compare(a.filename, b.filename); if (key === 'host') return dir * _collatorDE.compare(a.host, b.host); if (key === 'link') return dir * _collatorDE.compare(a.link, b.link); return 0; }); return sorted; } function updateRecentSortHeaders() { const head = document.getElementById('recentFilesHead'); if (!head) return; head.querySelectorAll('th[data-recent-sort]').forEach(th => { const key = th.dataset.recentSort; const active = recentSortState.key === key; const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕'; th.classList.toggle('active', active); const indicator = th.querySelector('.sort-indicator'); if (indicator) indicator.textContent = arrow; }); } let _recentListenersBound = false; function renderRecentUploadsPanel() { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; if (!sessionFilesData.length) { tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; return; } const rows = sortRecentFiles(sessionFilesData); tbody.innerHTML = rows.map(row => ` ${escapeHtml(row.date)} ${escapeHtml(row.filename)} ${escapeHtml(row.host)} ${escapeHtml(row.link)} `).join(''); // Event delegation – bind once, not per-row if (!_recentListenersBound) { _recentListenersBound = true; tbody.addEventListener('click', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; const id = parseInt(tr.dataset.order, 10); if (e.ctrlKey || e.metaKey) { if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); else selectedRecentIds.add(id); } else if (e.shiftKey && selectedRecentIds.size > 0) { const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order); const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); const curIdx = sortedOrders.indexOf(id); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]); } } else { selectedRecentIds.clear(); selectedRecentIds.add(id); } renderRecentUploadsPanel(); }); tbody.addEventListener('dblclick', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr || tr.classList.contains('error')) return; const link = tr.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); } updateRecentSortHeaders(); } function renderHistoryTable(container) { if (!container || !historyRowsData.length) { if (container) container.innerHTML = '

Noch keine Uploads.

'; return; } const rows = sortHistoryRows(historyRowsData); const headerCell = (key, label) => { const active = historySortState.key === key; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; return `${label}${dir}`; }; let html = ` ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} `; rows.forEach(row => { html += ``; }); html += '
${escapeHtml(row.date)} ${escapeHtml(row.filename)} ${escapeHtml(row.host)}
'; container.innerHTML = html; container.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { const key = th.dataset.historySort; if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; } renderHistoryTable(container); }); }); container.querySelectorAll('.history-row').forEach(row => { row.addEventListener('click', () => { if (row.classList.contains('error')) return; const link = row.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); }); } function sortHistoryRows(rows) { const { key, direction } = historySortState; const factor = direction === 'asc' ? 1 : -1; return rows.slice().sort((a, b) => { let cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || '')); return (cmp || a.order - b.order) * factor; }); } // Flush pending queue state on window close (sync IPC — blocks until save completes) window.addEventListener('beforeunload', () => { clearTimeout(queuePersistTimer); queuePersistTimer = null; const globalSettings = { ...(config.globalSettings || {}), pendingQueue: buildPersistedQueueState() }; config.globalSettings = globalSettings; window.api.saveGlobalSettingsSync(globalSettings); }); // --- Setup Listeners --- function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFolderBtn').addEventListener('click', pickFolder); document.getElementById('startUploadBtn').addEventListener('click', startUpload); document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); // Recent files sort headers document.getElementById('recentFilesHead').addEventListener('click', (e) => { const th = e.target.closest('th[data-recent-sort]'); if (!th) return; const key = th.dataset.recentSort; if (recentSortState.key === key) { recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; } else { recentSortState.key = key; recentSortState.direction = key === 'date' ? 'desc' : 'asc'; } renderRecentUploadsPanel(); }); // Recent files context menu document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; e.preventDefault(); e.stopPropagation(); const id = parseInt(tr.dataset.order, 10); if (!selectedRecentIds.has(id)) { selectedRecentIds.clear(); selectedRecentIds.add(id); renderRecentUploadsPanel(); } const menu = document.getElementById('recentContextMenu'); menu.style.display = 'block'; menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px'; }); document.getElementById('recentContextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; hideContextMenu(); const action = item.dataset.action; if (action === 'recent-copy-links') copySelectedRecentLinks(); else if (action === 'recent-delete') deleteSelectedRecentFiles(); }); document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads); document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top')); document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up')); document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down')); document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom')); document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual')); document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); document.getElementById('retryFailedBtn').addEventListener('click', () => { queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); retrySelectedJobs(); }); document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection); document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('selectAllHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = true; input.closest('.hoster-option')?.classList.add('selected'); }); }); document.getElementById('clearHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = false; input.closest('.hoster-option')?.classList.remove('selected'); }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); // --- Backup export / import (modal listeners stay here, button listeners in renderSettings) --- document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal); document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal); document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction); document.getElementById('backupPasswordModal').addEventListener('click', (e) => { if (e.target.id === 'backupPasswordModal') closeBackupModal(); }); document.getElementById('backupPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmBackupAction(); }); document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmBackupAction(); }); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; await window.api.clearHistory(); loadHistory(); }); // Auto health check toggle const autoToggle = document.getElementById('autoHealthCheckToggle'); if (autoToggle) { autoToggle.checked = autoHealthCheckEnabled; autoToggle.addEventListener('change', (e) => { autoHealthCheckEnabled = !!e.target.checked; try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} }); } // Virtual scroll for large queues const queueContainer = document.getElementById('queueContainer'); if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true }); // Queue table sorting document.querySelectorAll('#queueTable th.sortable').forEach(th => { th.addEventListener('click', () => { const key = th.dataset.sort; if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc'; else { queueSortState.key = key; queueSortState.direction = 'asc'; } _lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort renderQueueTable(); }); }); // Shutdown cancel document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { await window.api.cancelShutdown(); if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } document.getElementById('shutdownOverlay').style.display = 'none'; }); // Click on empty area in queue → deselect all document.getElementById('upload-view').addEventListener('click', (e) => { if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); } } }); // Right-click on upload view background document.getElementById('upload-view').addEventListener('contextmenu', (e) => { if (e.target.closest('.queue-row')) return; // handled per row if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue e.preventDefault(); showContextMenu(e.clientX, e.clientY); }); document.getElementById('hosterModal').addEventListener('click', (e) => { if (e.target.id === 'hosterModal') cancelHosterModal(); }); // Account management document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null)); document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('saveAccountBtn').addEventListener('click', saveAccount); document.getElementById('accountModal').addEventListener('click', (e) => { if (e.target.id === 'accountModal') closeAccountModal(); }); // Account hoster select change → update credential fields document.getElementById('accountHosterSelect').addEventListener('change', (e) => { const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const authType = opt ? opt.authType : 'login'; const credsContainer = document.getElementById('accountCredsFields'); credsContainer.innerHTML = getCredsFieldsHtml(authType, {}); credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); document.getElementById('accountModalStatus').textContent = ''; document.getElementById('accountModalStatus').className = 'account-modal-status'; }); // Delete account modal document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal); document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal); document.getElementById('confirmDeleteBtn').addEventListener('click', () => { const modal = document.getElementById('deleteAccountModal'); const accountId = modal.dataset.accountId; if (accountId) deleteAccount(accountId); }); document.getElementById('deleteAccountModal').addEventListener('click', (e) => { if (e.target.id === 'deleteAccountModal') closeDeleteModal(); }); } // --- Update UI --- function showUpdateBanner(info) { const banner = document.getElementById('updateBanner'); const msg = document.getElementById('updateMessage'); if (!banner || !msg) return; msg.textContent = `Update v${info.remoteVersion} verfügbar`; banner.style.display = 'flex'; document.getElementById('installUpdateBtn').onclick = async () => { msg.textContent = 'Update wird heruntergeladen...'; document.getElementById('installUpdateBtn').disabled = true; await persistQueueStateNow().catch(() => {}); // Save queue before update restart await window.api.installUpdate(); }; document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; }; } function handleUpdateProgress(data) { const msg = document.getElementById('updateMessage'); if (!msg) return; if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'error') { msg.textContent = `Update fehlgeschlagen: ${data.error}`; const btn = document.getElementById('installUpdateBtn'); if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } } } // --- Shutdown --- let shutdownCountdownInterval = null; function handleShutdownCountdown(data) { const overlay = document.getElementById('shutdownOverlay'); const msgEl = document.getElementById('shutdownMessage'); const secEl = document.getElementById('shutdownSeconds'); overlay.style.display = 'flex'; const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' }; let remaining = data.seconds || 60; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = setInterval(() => { remaining--; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (remaining <= 0) { clearInterval(shutdownCountdownInterval); } }, 1000); } // --- Link operations --- function copyAllLinks() { const links = queueJobs .filter(j => j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); if (links.length > 0) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Utilities --- function formatSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatSpeed(kbs) { if (!kbs || kbs <= 0) return '0 kB/s'; if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s'; return Math.round(kbs) + ' kB/s'; } function formatTime(seconds) { if (!seconds || seconds <= 0) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`; return `${pad(m)}:${pad(s)}`; } function pad(n) { return String(Math.floor(n)).padStart(2, '0'); } function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); const safeDate = Number.isNaN(date.getTime()) ? new Date() : date; return { ts: safeDate.getTime(), text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }; } function loadAutoCheckPreference() { try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; } catch { return true; } } function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escapeAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); } function showCopyToast(msg) { const toast = document.getElementById('copyToast'); toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('show'), 1500); } // --- Resize handle for recent-files panel --- { const resizer = document.getElementById('recentFilesResizer'); const panel = document.getElementById('recentFilesPanel'); if (resizer && panel) { let startY = 0; let startH = 0; resizer.addEventListener('mousedown', (e) => { e.preventDefault(); startY = e.clientY; startH = panel.getBoundingClientRect().height; resizer.classList.add('dragging'); document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; const onMove = (e2) => { const delta = startY - e2.clientY; const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta)); panel.style.flex = `0 0 ${newH}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); resizer.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } } // --- Recent panel tabs --- document.querySelectorAll('.recent-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active')); tab.classList.add('active'); const panel = document.getElementById(tab.dataset.panel); if (panel) panel.classList.add('active'); const hint = document.getElementById('recentFilesHint'); if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'; }); }); // --- Stats panel update --- let statsStartTime = 0; let statsRunTimer = null; function formatBytes(bytes) { if (bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; } function formatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function updateStatsPanel() { const stats = _computeQueueStats(); const remaining = stats.total - stats.done - stats.errors; const el = (id) => document.getElementById(id); if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total; if (el('statQueueDone')) el('statQueueDone').textContent = stats.done; if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining; if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress; if (el('statQueueError')) el('statQueueError').textContent = stats.errors; if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize); if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize); const speed = lastUploadStats.globalSpeedKbs || 0; if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s'; if (el('statEta')) { if (speed > 0 && stats.remainingSize > 0) { el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024))); } else { el('statEta').textContent = '--:--'; } } if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0); } // --- Start --- init();