diff --git a/README.md b/README.md index bf0b300..1906082 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.5. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.6. Use the release page for the executables and the full English changelog. ## Features diff --git a/main.js b/main.js index ad88be0..b830100 100644 --- a/main.js +++ b/main.js @@ -1402,10 +1402,10 @@ function createWindow() { try { const choice = dialog.showMessageBoxSync(mainWindow, { type: 'error', - title: 'Renderer abgestürzt', - message: `Der Renderer-Prozess ist abgestürzt (${details.reason}).`, - detail: 'Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.', - buttons: ['Neu laden', 'Beenden'], + title: shellText('Renderer abgestürzt', 'Renderer crashed'), + message: shellText(`Der Renderer-Prozess ist abgestürzt (${details.reason}).`, `The renderer process crashed (${details.reason}).`), + detail: shellText('Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.', 'Export and send a diagnostics package. Click "Reload" to restore the interface; uploads running in the main process remain active.'), + buttons: [shellText('Neu laden', 'Reload'), shellText('Beenden', 'Quit')], defaultId: 0, cancelId: 1 }); @@ -1460,14 +1460,7 @@ function createTray() { } catch {} } tray = new Tray(icon || nativeImage.createEmpty()); - tray.setToolTip('Multi-Hoster-Upload'); - - const contextMenu = Menu.buildFromTemplate([ - { label: 'Öffnen', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, - { type: 'separator' }, - { label: 'Beenden', click: () => { app.quit(); } } - ]); - tray.setContextMenu(contextMenu); + refreshTrayLanguage(); tray.on('click', () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } @@ -1702,7 +1695,7 @@ ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) = ? filters : [{ name: 'Textdatei', extensions: ['txt', 'csv', 'log'] }]; const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { - title: 'Speichern unter', + title: shellText('Speichern unter', 'Save as'), defaultPath: safeName, filters: safeFilters }); @@ -1718,11 +1711,11 @@ ipcMain.handle('export-history', async (_event, format) => { const datePrefix = new Date().toISOString().slice(0, 10); const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { - title: 'Upload-Verlauf exportieren', + title: shellText('Upload-Verlauf exportieren', 'Export upload history'), defaultPath: `upload-history-${datePrefix}.${normalizedFormat}`, filters: normalizedFormat === 'json' - ? [{ name: 'JSON-Datei', extensions: ['json'] }] - : [{ name: 'CSV-Datei', extensions: ['csv'] }] + ? [{ name: shellText('JSON-Datei', 'JSON file'), extensions: ['json'] }] + : [{ name: shellText('CSV-Datei', 'CSV file'), extensions: ['csv'] }] }); if (canceled || !filePath) return { ok: false, canceled: true }; @@ -1810,6 +1803,25 @@ function getUploadBrowseDirectory() { return app.getPath('downloads'); } +function getConfiguredLanguage() { + try { return configStore.load().globalSettings?.language === 'de' ? 'de' : 'en'; } + catch { return 'en'; } +} + +function shellText(german, english) { + return getConfiguredLanguage() === 'de' ? german : english; +} + +function refreshTrayLanguage() { + if (!tray || tray.isDestroyed()) return; + tray.setToolTip('Multi Hoster Uploader'); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: shellText('Öffnen', 'Open'), click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, + { type: 'separator' }, + { label: shellText('Beenden', 'Quit'), click: () => { app.quit(); } } + ])); +} + async function rememberUploadBrowseDirectory(selectedPath, selectedDirectory = false) { if (!selectedPath) return; const directory = selectedDirectory ? selectedPath : path.dirname(selectedPath); @@ -1821,8 +1833,8 @@ ipcMain.handle('select-files', async () => { defaultPath: getUploadBrowseDirectory(), properties: ['openFile', 'multiSelections'], filters: [ - { name: 'Alle Dateien', extensions: ['*'] }, - { name: 'Videos', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] } + { name: shellText('Alle Dateien', 'All files'), extensions: ['*'] }, + { name: shellText('Videos', 'Videos'), extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] } ] }); if (result.canceled || !result.filePaths.length) return null; @@ -2368,7 +2380,7 @@ ipcMain.handle('create-support-bundle', async () => { const defaultName = `multi-hoster-support-${stamp}.txt`; const desktop = (() => { try { return app.getPath('desktop'); } catch { return app.getPath('userData'); } })(); const res = await dialog.showSaveDialog(mainWindow || undefined, { - title: 'Diagnose-Paket speichern', + title: shellText('Diagnose-Paket speichern', 'Save diagnostics package'), defaultPath: path.join(desktop, defaultName), filters: [{ name: 'Text', extensions: ['txt'] }] }); @@ -2516,11 +2528,11 @@ ipcMain.handle('export-backup', async () => { const _bd = new Date(); const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`; const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { - title: 'Backup exportieren', + title: shellText('Backup exportieren', 'Export backup'), defaultPath: `${_bdate}-multihoster-backup.mhu`, filters: [ - { name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] }, - { name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] } + { name: shellText('Multi-Hoster Backup (verschlüsselt)', 'Multi Hoster backup (encrypted)'), extensions: ['mhu'] }, + { name: shellText('Multi-Hoster Backup (Klartext JSON)', 'Multi Hoster backup (plain JSON)'), extensions: ['json'] } ] }); if (canceled || !filePath) return { ok: false, canceled: true }; @@ -2542,9 +2554,9 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { buffer = readBackupFile(sourcePath); } else { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { - title: 'Backup importieren', + title: shellText('Backup importieren', 'Import backup'), filters: [ - { name: 'Multi-Hoster Backup', extensions: ['mhu', 'json'] }, + { name: 'Multi Hoster backup', extensions: ['mhu', 'json'] }, { name: 'Verschlüsselt (.mhu)', extensions: ['mhu'] }, { name: 'Klartext (.json)', extensions: ['json'] } ], @@ -2642,10 +2654,10 @@ ipcMain.handle('read-own-upload-log', () => { ipcMain.handle('import-upload-log', async () => { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { - title: 'Upload-Log importieren', + title: shellText('Upload-Log importieren', 'Import upload log'), filters: [ - { name: 'Log-Dateien', extensions: ['log', 'txt'] }, - { name: 'Alle Dateien', extensions: ['*'] } + { name: shellText('Log-Dateien', 'Log files'), extensions: ['log', 'txt'] }, + { name: shellText('Alle Dateien', 'All files'), extensions: ['*'] } ], properties: ['openFile'] }); @@ -2807,6 +2819,7 @@ ipcMain.handle('save-global-settings', async (_event, globalSettings) => { if (uploadManager) { try { uploadManager.updateSettings(null, globalSettings); } catch (error) { debugLog(`global settings runtime update failed: ${error.message}`); } } + refreshTrayLanguage(); return true; }); diff --git a/package-lock.json b/package-lock.json index 6e06ed0..7708c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.5", + "version": "2.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.5", + "version": "2.1.6", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 17f55c0..8e97b5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.5", + "version": "2.1.6", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 7727560..66305b1 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3,13 +3,43 @@ const uiLocalizer = window.I18n.createDomLocalizer(document); uiLocalizer.start('en'); function setUiLanguage(value) { - return uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value)); + const language = uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value)); + if (document.body && typeof queueJobs !== 'undefined') refreshLocalizedRuntimeUi(); + return language; } function getUiLocale() { return uiLocalizer.getLanguage() === 'de' ? 'de-DE' : 'en-US'; } +function localizeUiText(value) { + return window.I18n.translateText(value, uiLocalizer.getLanguage()); +} + +function formatRemoteClientStatus(port, count) { + const clients = count === 1 ? '1 Client' : `${count} Clients`; + return localizeUiText(`Aktiv auf Port ${port} — ${clients} verbunden`); +} + +function refreshLocalizedRuntimeUi() { + renderQueueTable(); + renderAccounts(); + renderRecentUploadsPanel(); + historyRowsData.forEach(row => { + if (row.rawTimestamp !== undefined) { + const formatted = formatDateTime(row.rawTimestamp); + row.date = formatted.text; + row.dateTs = formatted.ts; + } + }); + const historyContainer = document.getElementById('historyContainer'); + if (historyContainer && historyRowsData.length) renderHistoryTable(historyContainer); + updateStatusBar(); + const activeRecentTab = document.querySelector('.recent-tab.active'); + const hint = document.getElementById('recentFilesHint'); + if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'); +} + // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, @@ -492,7 +522,7 @@ async function init() { 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`; + el.textContent = formatRemoteClientStatus(status.port, status.clientCount); } }).catch(() => {}); } @@ -627,6 +657,7 @@ function initMenuBar() { for (const k in dropdowns) closePanel(dropdowns[k]); for (const k in triggers) triggers[k].classList.remove('open'); menuBar.querySelectorAll('.menu-submenu-dropdown').forEach(closePanel); + menuBar.querySelectorAll('.menu-submenu-trigger').forEach(trigger => trigger.setAttribute('aria-expanded', 'false')); } function openMenuNamed(name) { @@ -649,8 +680,34 @@ function initMenuBar() { menuBar.querySelectorAll('.menu-submenu').forEach(sm => { const sub = sm.querySelector('.menu-submenu-dropdown'); - sm.addEventListener('mouseenter', () => openPanel(sub)); - sm.addEventListener('mouseleave', () => closePanel(sub)); + const trigger = sm.querySelector('.menu-submenu-trigger'); + const openSubmenu = (focusFirst = false) => { + openPanel(sub); + trigger?.setAttribute('aria-expanded', 'true'); + if (focusFirst) requestAnimationFrame(() => sub.querySelector('[data-menu-action]')?.focus()); + }; + const closeSubmenu = () => { + closePanel(sub); + trigger?.setAttribute('aria-expanded', 'false'); + }; + sm.addEventListener('mouseenter', () => openSubmenu()); + sm.addEventListener('mouseleave', closeSubmenu); + trigger?.addEventListener('click', (event) => { + event.stopPropagation(); + if (trigger.getAttribute('aria-expanded') === 'true') closeSubmenu(); + else openSubmenu(); + }); + trigger?.addEventListener('keydown', (event) => { + if (!['Enter', ' ', 'ArrowRight', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + openSubmenu(true); + }); + sub.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return; + event.preventDefault(); + closeSubmenu(); + trigger?.focus(); + }); }); menuBar.querySelectorAll('[data-menu-action]').forEach(item => { @@ -676,7 +733,7 @@ async function _handleMenuAction(action) { case 'backup-import': doBackupImport(); break; case 'online-backup-create': doOnlineBackupCreate(); break; case 'online-backup-restore': openOnlineBackupRestore(); break; - case 'restart': if (confirm('Anwendung neu starten?')) window.api.restartApp(); break; + case 'restart': if (await showAppConfirm({ title: 'Anwendung neu starten?', message: 'Nicht gespeicherte laufende Aktionen werden beendet.', confirmText: 'Neustart', danger: true })) window.api.restartApp(); break; case 'quit': window.api.quitApp(); break; case 'open-settings': document.querySelector('.tab[data-view="settings"]')?.click(); break; case 'open-log-folder': window.api.openLogFolder(); break; @@ -1234,6 +1291,11 @@ function setupDragDrop() { addDroppedFiles(e.dataTransfer.files).catch(console.error); }); dropZone.addEventListener('click', () => pickFiles()); + dropZone.addEventListener('keydown', (event) => { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + pickFiles(); + }); // Also handle drops on queue container uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); @@ -1425,6 +1487,21 @@ function updateQueueActionButtons() { if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; + syncDataActionState(); +} + +function syncDataActionState() { + const completedLinks = queueJobs.some(job => job.status === 'done' && (job.result?.download_url || job.result?.embed_url)); + const hasRecent = sessionFilesData.length > 0; + const hasHistory = historyRowsData.length > 0; + const copyAll = document.getElementById('copyAllLinksBtn'); + const clearRecent = document.getElementById('clearRecentFilesBtn'); + const exportRecent = document.getElementById('exportRecentFilesBtn'); + const exportHistoryButton = document.getElementById('exportHistoryBtn'); + if (copyAll) copyAll.disabled = !completedLinks; + if (clearRecent) clearRecent.disabled = !hasRecent; + if (exportRecent) exportRecent.disabled = !hasRecent; + if (exportHistoryButton) exportHistoryButton.disabled = !hasHistory; } function clearDedupKeysForPaths(pathSet) { @@ -1647,11 +1724,11 @@ function buildRowHtml(job) { const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; - return ` + return ` ${escapeHtml(job.fileName)} ${uploadedSize} ${escapeHtml(job.hoster)} - ${escapeHtml(statusText)} + ${escapeHtml(statusText)} ${elapsed} ${remaining} ${speed} @@ -1684,6 +1761,7 @@ function _updateRowInPlace(tr, job) { const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; if (tr.className !== newClass) tr.className = newClass; if (tr.dataset.link !== link) tr.dataset.link = link; + tr.setAttribute('aria-selected', String(selectedJobIds.has(job.id))); const cells = tr.children; if (cells.length < 8) return false; // structure mismatch, needs full rebuild @@ -1694,8 +1772,10 @@ function _updateRowInPlace(tr, job) { if (badge) { const badgeClass = `status-badge ${statusClass}`; if (badge.className !== badgeClass) badge.className = badgeClass; - if (badge.textContent !== statusText) badge.textContent = statusText; + const label = badge.querySelector('.status-badge-label') || badge; + if (label.textContent !== statusText) label.textContent = statusText; } + if (cells[3].title !== statusText) cells[3].title = statusText; if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed; if (cells[5].textContent !== remaining) cells[5].textContent = remaining; if (cells[6].textContent !== speed) cells[6].textContent = speed; @@ -1769,6 +1849,10 @@ function renderQueueTable() { _sortedJobsCache = sortQueueJobs(visibleJobs); if (selectionChanged) updateQueueActionButtons(); const totalRows = _sortedJobsCache.length; + const queueContainer = document.getElementById('queueContainer'); + const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && uploadSidebarFilter !== 'all'; + queueContainer?.classList.toggle('filter-empty', filteredEmpty); + if (queueContainer) queueContainer.dataset.emptyLabel = filteredEmpty ? localizeUiText('Keine Uploads entsprechen diesem Filter.') : ''; if (totalRows < 200) { // Try in-place update if row count matches (fast path) @@ -1807,14 +1891,36 @@ function renderQueueTable() { const row = e.target.closest('.queue-row'); if (row) handleRowContextMenu(e, row); }); + tbody.addEventListener('keydown', (e) => { + const row = e.target.closest('.queue-row'); + if (!row) return; + if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') { + e.preventDefault(); + const rect = row.getBoundingClientRect(); + handleRowContextMenu({ preventDefault() {}, clientX: rect.left + 24, clientY: rect.top + 20 }, row); + return; + } + if (['Enter', ' '].includes(e.key)) { + e.preventDefault(); + handleRowClick(e, row); + } + }); } // Update retry button visibility const hasFailedJobs = queueJobs.some(j => j.status === 'error'); document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; + syncQueueSortHeaders(); updateQueueActionButtons(); } +function syncQueueSortHeaders() { + document.querySelectorAll('#queueTable th.sortable').forEach(header => { + const active = header.dataset.sort === queueSortState.key; + header.setAttribute('aria-sort', active ? (queueSortState.direction === 'asc' ? 'ascending' : 'descending') : 'none'); + }); +} + function _renderVirtualRows(tbody) { const scrollContainer = document.getElementById('queueContainer'); if (!scrollContainer) return; @@ -1945,28 +2051,31 @@ function getAccountLabel(job) { if (!Array.isArray(accounts)) return ''; const idx = accounts.findIndex(a => a && a.id === job.accountId); if (idx < 0) return ''; - return idx === 0 ? 'Primär' : `Fallback #${idx}`; + return idx === 0 ? localizeUiText('Primär') : `Fallback #${idx}`; } function getStatusText(job) { const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; const acc = getAccountLabel(job); const accSuffix = acc ? ` · ${acc}` : ''; + let text; switch (job.status) { - case 'preview': return 'Bereit'; - case 'queued': return 'Wartet'; - case 'getting-server': return `Server...${accSuffix}`; - case 'uploading': return `Upload${accSuffix}`; + case 'preview': text = 'Bereit'; break; + case 'queued': text = 'Wartet'; break; + case 'getting-server': text = `Server…${accSuffix}`; break; + case 'uploading': text = `Upload${accSuffix}`; break; case 'retrying': { const base = `Retry ${job.attempt}/${job.maxAttempts}${accSuffix}`; - return shortErr ? `${base}: ${shortErr}` : base; + text = shortErr ? `${base}: ${shortErr}` : base; + break; } - case 'done': return 'Fertig'; - case 'aborted': return 'Abgebrochen'; - case 'error': return shortErr ? `Fehlgeschlagen: ${shortErr}` : 'Fehlgeschlagen'; - case 'skipped': return shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; - default: return job.status; + case 'done': text = 'Fertig'; break; + case 'aborted': text = shortErr || 'Abgebrochen'; break; + case 'error': text = shortErr ? (/^Fehlgeschlagen(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break; + case 'skipped': text = shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; break; + default: text = job.status; } + return localizeUiText(text); } // --- Queue interactions --- @@ -2013,6 +2122,7 @@ let alwaysOnTopState = false; // to queueJobs (the length-based signature is good enough — a job's hoster // never changes after it's created). let _hosterCountsCache = { sig: '', result: new Map() }; +let contextMenuReturnFocus = null; function _getHosterCounts() { const sig = `${queueJobs.length}`; if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result; @@ -2066,6 +2176,8 @@ function showContextMenu(x, y) { const item = document.createElement('div'); item.className = 'ctx-item ctx-item-danger'; item.dataset.action = `delete-hoster:${hoster}`; + item.setAttribute('role', 'menuitem'); + item.tabIndex = -1; item.textContent = `${getHosterLabel(hoster)} (${count})`; deleteHosterContainer.appendChild(item); }); @@ -2077,6 +2189,8 @@ function showContextMenu(x, y) { 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'; + contextMenuReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + getVisibleContextMenuItems(menu)[0]?.focus(); // Flip submenus if they would overflow the viewport right edge menu.querySelectorAll('.ctx-submenu-items').forEach(sub => { @@ -2089,13 +2203,37 @@ function showContextMenu(x, y) { }); } +function getVisibleContextMenuItems(menu) { + return [...menu.querySelectorAll('.ctx-item[data-action]')].filter(item => window.getComputedStyle(item).display !== 'none'); +} + +function showRecentContextMenu(row, x, y) { + const id = parseInt(row.dataset.order, 10); + if (!selectedRecentIds.has(id)) { + selectedRecentIds.clear(); + selectedRecentIds.add(id); + applyRecentSelectionClasses(); + } + const menu = document.getElementById('recentContextMenu'); + menu.style.display = 'block'; + menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; + contextMenuReturnFocus = row; + getVisibleContextMenuItems(menu)[0]?.focus(); +} + function hideContextMenu() { document.getElementById('contextMenu').style.display = 'none'; document.getElementById('recentContextMenu').style.display = 'none'; + const returnFocus = contextMenuReturnFocus; + contextMenuReturnFocus = null; + if (returnFocus?.isConnected) returnFocus.focus(); } -function deleteSelectedRecentFiles() { +async function deleteSelectedRecentFiles() { if (selectedRecentIds.size === 0) return; + const count = selectedRecentIds.size; + if (!await showAppConfirm({ title: 'Ausgewählte Einträge entfernen?', message: count === 1 ? 'Ein ausgewählter Eintrag wird aus diesem Panel entfernt.' : `${count} ausgewählte Einträge werden aus diesem Panel entfernt.`, confirmText: 'Entfernen', danger: true })) return; let removedDone = 0, removedErr = 0; sessionFilesData = sessionFilesData.filter(r => { if (!selectedRecentIds.has(r.order)) return true; @@ -2110,9 +2248,9 @@ function deleteSelectedRecentFiles() { renderRecentUploadsPanel(); } -function clearAllRecentFiles() { +async function clearAllRecentFiles() { if (sessionFilesData.length === 0) return; - if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return; + if (!await showAppConfirm({ title: 'Alle Links entfernen?', message: `Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`, confirmText: 'Alle entfernen', danger: true })) return; sessionFilesData = []; _sessionFileKeys.clear(); _sessionDoneCount = 0; @@ -2124,7 +2262,7 @@ function clearAllRecentFiles() { async function exportAllRecentFiles() { if (sessionFilesData.length === 0) { - alert('Keine Einträge zum Exportieren.'); + await showAppAlert('Keine Einträge zum Exportieren.'); return; } const rows = sortRecentFiles(sessionFilesData); @@ -2145,7 +2283,7 @@ async function exportAllRecentFiles() { ]); if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`); } catch (err) { - alert('Export fehlgeschlagen: ' + (err.message || err)); + await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); } } @@ -2164,7 +2302,7 @@ async function doBackupExport() { const result = await window.api.exportBackup(); if (result && result.ok) showCopyToast('Backup exportiert'); } catch (err) { - alert('Export fehlgeschlagen: ' + (err.message || err)); + await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); } } @@ -2392,14 +2530,14 @@ async function doBackupImport(legacyPassword) { } if (result.ok) { if (Array.isArray(result.warnings) && result.warnings.length) { - alert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`); + await showAppAlert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`, 'Backup importiert'); } if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); } else if (result.error) { - alert('Import fehlgeschlagen: ' + result.error); + await showAppAlert('Import fehlgeschlagen: ' + result.error, 'Import fehlgeschlagen'); } } catch (err) { - alert('Import fehlgeschlagen: ' + (err.message || err)); + await showAppAlert('Import fehlgeschlagen: ' + (err.message || err), 'Import fehlgeschlagen'); } } @@ -2449,29 +2587,7 @@ document.addEventListener('keydown', (e) => { 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); - const _deletedKeys = []; - queueJobs = queueJobs.filter(j => { - if (selectedJobIds.has(j.id)) { - if (j.file && j.hoster && j.status !== 'done') _deletedKeys.push(`${j.file}|${j.hoster}`); - removeJobFromIndex(j); - return false; - } - return true; - }); - selectedJobIds.clear(); - syncSelectedFilesFromQueue(); - suppressPreviewKeysStillSelected(_deletedKeys); - renderQueueTable(); - if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } - updateStatusBar(); - persistQueueStateSoon(true); + handleContextAction('delete-selected'); } } } @@ -2498,7 +2614,8 @@ async function handleContextAction(action) { } else if (action === 'show-log') { showJobLogModal(); } else if (action === 'delete-selected') { - // Cancel active uploads for deleted jobs + const count = selectedJobIds.size; + if (!count || !await showAppConfirm({ title: 'Uploads entfernen?', message: count === 1 ? 'Ein ausgewählter Upload wird aus der Liste entfernt.' : `${count} ausgewählte Uploads werden aus der Liste entfernt.`, confirmText: 'Entfernen', danger: true })) return; 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'); @@ -2523,7 +2640,7 @@ async function handleContextAction(action) { } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { - // Cancel all active uploads + if (!queueJobs.length || !await showAppConfirm({ title: 'Alle Uploads entfernen?', message: `${queueJobs.length} ${queueJobs.length === 1 ? 'Upload wird' : 'Uploads werden'} aus der Liste entfernt.`, confirmText: 'Alle entfernen', danger: true })) return; const activeIds = queueJobs .filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') .map(j => j.id); @@ -2543,7 +2660,8 @@ async function handleContextAction(action) { config.globalSettings = { ...(config.globalSettings || {}), alwaysOnTop: alwaysOnTopState }; } else if (action.startsWith('delete-hoster:')) { const hoster = action.replace('delete-hoster:', ''); - // Cancel active uploads for this hoster + const hosterCount = queueJobs.filter(job => job.hoster === hoster).length; + if (!hosterCount || !await showAppConfirm({ title: 'Hoster entfernen?', message: `${hosterCount} ${hosterCount === 1 ? 'Upload wird' : 'Uploads werden'} für ${getHosterLabel(hoster)} entfernt.`, confirmText: 'Hoster entfernen', danger: true })) return; 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); @@ -2584,7 +2702,7 @@ async function startUpload(opts) { const hosters = getSelectedHosters(); if (queueJobs.length === 0 && selectedFiles.length > 0) { if (hosters.length === 0) { - alert('Bitte mindestens einen Hoster auswählen.'); + await showAppAlert('Bitte mindestens einen Hoster auswählen.'); uploading = false; updateQueueActionButtons(); return; @@ -2626,7 +2744,7 @@ async function startUpload(opts) { persistQueueStateSoon(); if (result && result.error) { - alert(result.error); + await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); @@ -2635,7 +2753,7 @@ async function startUpload(opts) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - alert(`Upload-Start fehlgeschlagen: ${err.message}`); + await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); } } @@ -2741,7 +2859,7 @@ async function startSelectedUpload(explicitJobs) { persistQueueStateSoon(); if (result && result.error) { - alert(result.error); + await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); @@ -2750,7 +2868,7 @@ async function startSelectedUpload(explicitJobs) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - alert(`Upload-Start fehlgeschlagen: ${err.message}`); + await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); } } @@ -3076,7 +3194,7 @@ function _retryFailedFromBuckets(buckets, transientOnly) { } if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } renderQueueTable(); - showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`); + showCopyToast(jobsToRetry.length === 1 ? '1 Job zum erneuten Upload zurückgesetzt' : `${jobsToRetry.length} Jobs zum erneuten Upload zurückgesetzt`); if (typeof startUpload === 'function') startUpload(); } @@ -3263,6 +3381,8 @@ async function finishUploadsInProgress() { } async function abortAllUploads() { + if (!uploading) return; + if (!await showAppConfirm({ title: 'Alle Uploads abbrechen?', message: 'Alle laufenden Uploads werden abgebrochen und in die Warteschlange zurückgesetzt.', confirmText: 'Alle abbrechen', danger: true })) return; await cancelUpload(); } @@ -3553,16 +3673,30 @@ function _getAccountSidebarCategory(name, account) { function _applyAccountSidebarFilter() { const entries = new Map(getAllAccountsFlat().map(entry => [entry.account.id, entry])); + const list = document.getElementById('accountsList'); + let visibleAccounts = 0; document.querySelectorAll('#accountsList .account-hoster-group').forEach(group => { let matches = 0; group.querySelectorAll('.account-card').forEach(card => { const entry = entries.get(card.dataset.accountId); const visible = !!entry && (accountSidebarFilter === 'all' || _getAccountSidebarCategory(entry.name, entry.account) === accountSidebarFilter); card.hidden = !visible; - if (visible) matches++; + if (visible) { matches++; visibleAccounts++; } }); group.hidden = matches === 0; }); + let empty = list?.querySelector('.account-filter-empty'); + if (visibleAccounts === 0 && entries.size > 0) { + if (!empty) { + empty = document.createElement('div'); + empty.className = 'empty-state account-filter-empty'; + list.appendChild(empty); + } + empty.textContent = localizeUiText('Keine Accounts entsprechen diesem Filter.'); + empty.hidden = false; + } else if (empty) { + empty.hidden = true; + } } function setAccountSidebarFilter(value) { @@ -3759,32 +3893,77 @@ function renderHealthCheckResults(_results) { } let _appAlertResolve = null; +let _appAlertReturnFocus = null; +let _appAlertInertState = []; -function closeAppAlert() { +function _setAppAlertBackgroundInert(active) { + const modal = document.getElementById('appAlertModal'); + if (!modal) return; + if (active) { + if (_appAlertInertState.length > 0) return; + _appAlertInertState = Array.from(document.body.children) + .filter(element => element !== modal && 'inert' in element) + .map(element => ({ element, inert: element.inert })); + _appAlertInertState.forEach(({ element }) => { element.inert = true; }); + return; + } + _appAlertInertState.forEach(({ element, inert }) => { + if (element.isConnected) element.inert = inert; + }); + _appAlertInertState = []; +} + +function closeAppAlert(result = false) { const modal = document.getElementById('appAlertModal'); if (!modal) return; modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); + _setAppAlertBackgroundInert(false); const resolve = _appAlertResolve; _appAlertResolve = null; - if (resolve) resolve(); + const returnFocus = _appAlertReturnFocus; + _appAlertReturnFocus = null; + if (returnFocus?.isConnected) returnFocus.focus(); + if (resolve) resolve(result); } -function showAppAlert(message, title = 'Hinweis') { +function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelText = 'Abbrechen', alternateText = '', showCancel = false, danger = false } = {}) { const modal = document.getElementById('appAlertModal'); const titleEl = document.getElementById('appAlertTitle'); const messageEl = document.getElementById('appAlertMessage'); const confirm = document.getElementById('appAlertConfirmBtn'); - if (!modal || !titleEl || !messageEl || !confirm) return Promise.resolve(); - if (_appAlertResolve) closeAppAlert(); - titleEl.textContent = title; - messageEl.textContent = String(message || ''); + const cancel = document.getElementById('appAlertCancelBtn'); + const alternate = document.getElementById('appAlertAlternateBtn'); + if (!modal || !titleEl || !messageEl || !confirm || !cancel || !alternate) return Promise.resolve(false); + if (_appAlertResolve) closeAppAlert(false); + _appAlertReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + titleEl.textContent = localizeUiText(title); + messageEl.textContent = localizeUiText(String(message || '')); + confirm.textContent = localizeUiText(confirmText); + confirm.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`; + cancel.textContent = localizeUiText(cancelText); + cancel.hidden = !showCancel; + alternate.textContent = localizeUiText(alternateText); + alternate.hidden = !alternateText; modal.style.display = 'flex'; modal.setAttribute('aria-hidden', 'false'); - confirm.focus(); + _setAppAlertBackgroundInert(true); + (showCancel ? cancel : confirm).focus(); return new Promise(resolve => { _appAlertResolve = resolve; }); } +function showAppAlert(message, title = 'Hinweis') { + return showAppDialog({ message, title }); +} + +function showAppConfirm({ message, title = 'Bestätigen', confirmText = 'Bestätigen', cancelText = 'Abbrechen', danger = false } = {}) { + return showAppDialog({ message, title, confirmText, cancelText, showCancel: true, danger }); +} + +function showAppChoice({ message, title, confirmText, alternateText, cancelText = 'Abbrechen' } = {}) { + return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true }); +} + async function executeHealthCheck(hosters, _mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); @@ -3881,14 +4060,14 @@ function renderSettings() { const remoteSettings = globalSettings.remote || {}; const pageDefinitions = [ - { id: 'allgemein', label: 'Allgemein', search: 'fenster vordergrund drop target oberfläche updates aktualisierung version' }, - { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange fertig abschluss entfernen parallel geschwindigkeit speed limit fortsetzen wiederherstellen hoster' }, - { id: 'automatik', label: 'Automatik', search: 'automatisch retry wiederholen ordner überwachen dateierweiterungen unterordner duplikate' }, - { id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'webhook discord meldung ping erwähnung batch fertig' }, - { id: 'logs', label: 'Logs & Support', search: 'log protokoll debug verbose diagnose support paket datei ordner' }, - { id: 'remote', label: 'Fernsteuerung', search: 'remote fernsteuerung server input port api token verbindung' }, - { id: 'diagnose', label: 'Diagnose-Zugriff', search: 'diagnose zugriff lesen allowlist netzwerk lokal verbindung code support' }, - { id: 'backup', label: 'Backup & Übertragen', search: 'backup sichern export import online schlüssel einstellungen accounts übertragen' } + { id: 'allgemein', label: 'Allgemein', search: 'fenster window vordergrund foreground always on top drop target oberfläche interface updates update aktualisierung version language sprache' }, + { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster' }, + { id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' }, + { id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'benachrichtigungen notifications webhook discord meldung message ping erwähnung mention batch fertig completed' }, + { id: 'logs', label: 'Logs & Support', search: 'log logs protokoll logging debug verbose diagnose diagnostics support paket package datei file ordner folder' }, + { id: 'remote', label: 'Fernsteuerung', search: 'remote control fernsteuerung server input port api token verbindung connection client' }, + { id: 'diagnose', label: 'Diagnose-Zugriff', search: 'diagnose diagnostics access zugriff lesen read allowlist netzwerk network lokal local verbindung connection code support' }, + { id: 'backup', label: 'Backup & Übertragen', search: 'backup transfer sichern export import online schlüssel key einstellungen settings accounts übertragen' } ]; const pageHeader = (title, description) => `
@@ -4169,23 +4348,23 @@ function renderSettings() {
- +
- +
-
- +
@@ -4388,6 +4567,8 @@ function renderSettings() { }); document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => { + const confirmed = await showAppConfirm({ title: 'API-Token neu erzeugen?', message: 'Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', confirmText: 'Neu erzeugen', danger: true }); + if (!confirmed) return; const newToken = await window.api.remoteGenerateToken(); document.getElementById('remoteTokenInput').value = newToken; scheduleSettingsSave(); @@ -4397,7 +4578,7 @@ function renderSettings() { const el = document.getElementById('remoteConnectionStatus'); if (!el) return; if (status.running) { - el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; + el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} ${status.clientCount === 1 ? 'Client' : 'Clients'} verbunden`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; @@ -4439,7 +4620,7 @@ function renderSettings() { const b = document.createElement('button'); b.className = 'btn btn-xs btn-secondary'; b.textContent = h; - b.addEventListener('click', () => { publicHostEl.value = h; save(); }); + b.addEventListener('click', () => { publicHostEl.value = h; markSettingsDirty(); }); suggestChips.appendChild(b); } } else { @@ -4470,7 +4651,7 @@ function renderSettings() { if (st.running) { const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString(getUiLocale()) : '—'; const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal'; - el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`; + el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; @@ -4478,32 +4659,23 @@ function renderSettings() { } }).catch(() => {}); }; - const save = async () => { + const validate = () => { const allowlist = parseAllowlist(); if (enabledEl.checked && modeEl.value === 'network' && allowlist.length === 0) { if (bindHintEl) { bindHintEl.innerHTML = 'Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.'; } - return; + return false; } - const diagnosticsSettings = { - enabled: enabledEl.checked, - port: parseInt(portEl.value, 10) || 9110, - bindMode: modeEl.value, - publicHost: publicHostEl.value.trim(), - allowlist - }; - await saveDiagnosticsSettingsTracked(diagnosticsSettings); - applySettings(await window.api.diagnosticsGetSettings()); - refreshStatus(); + return true; }; window.api.diagnosticsGetSettings().then(applySettings).catch(() => {}); refreshStatus(); - enabledEl.addEventListener('change', save); - portEl.addEventListener('change', save); - modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); save(); }); - publicHostEl.addEventListener('change', save); - allowlistEl.addEventListener('change', save); + enabledEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + portEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); if (validate()) markSettingsDirty(); }); + publicHostEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + allowlistEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => { if (!codeEl.value) return; await window.api.copyToClipboard(codeEl.value); @@ -4512,6 +4684,8 @@ function renderSettings() { setTimeout(() => { b.textContent = 'Kopieren'; }, 1500); }); document.getElementById('diagRegenerateBtn').addEventListener('click', async () => { + const confirmed = await showAppConfirm({ title: 'Verbindungs-Code neu erzeugen?', message: 'Der bisherige Diagnose-Code wird sofort ungültig.', confirmText: 'Neu erzeugen', danger: true }); + if (!confirmed) return; const r = await runConfigWrite(() => window.api.diagnosticsRegenerate()); if (r && r.code) { codeEl.value = r.code; issuedEl.textContent = fmtIssued(r.codeIssuedAt); } refreshStatus(); @@ -4735,10 +4909,21 @@ async function performSaveSettings(options = {}) { newHosterSettings[name] = hs; } - await Promise.all([ + const saves = [ saveHosterSettingsTracked(newHosterSettings), saveGlobalSettingsTracked(globalSettings) - ]); + ]; + const diagnosticsEnabled = document.getElementById('diagEnabledInput'); + if (diagnosticsEnabled) { + saves.push(saveDiagnosticsSettingsTracked({ + enabled: diagnosticsEnabled.checked, + port: Math.min(65535, Math.max(1024, parseInt(document.getElementById('diagPortInput')?.value, 10) || 9110)), + bindMode: document.getElementById('diagBindModeInput')?.value === 'network' ? 'network' : 'local', + publicHost: document.getElementById('diagPublicHostInput')?.value.trim() || '', + allowlist: (document.getElementById('diagAllowlistInput')?.value || '').split(/\r?\n/).map(value => value.trim()).filter(Boolean) + })); + } + await Promise.all(saves); config.hosterSettings = newHosterSettings; config.globalSettings = globalSettings; hosterSettings = newHosterSettings; @@ -4782,7 +4967,7 @@ async function performSaveSettings(options = {}) { const statusEl = document.getElementById('remoteConnectionStatus'); if (statusEl) { if (status.running) { - statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; + statusEl.textContent = formatRemoteClientStatus(status.port, status.clientCount); statusEl.style.color = '#10b981'; } else { statusEl.textContent = 'Nicht aktiv'; @@ -4840,7 +5025,7 @@ function _buildAccountCardHtml(name, account, idx) { return `