From 7c97915d649a88883063ede01c7feefa6960eb0d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:19:51 +0200 Subject: [PATCH] fix: complete localized accessible UI audit Localize dynamic renderer and shell text in English and German, replace native prompts with safe styled dialogs, align empty and dirty states, restore compact responsive information, and add keyboard support for queue, account, menu, and sorting interactions. Version the verified patch as 2.1.6. --- README.md | 2 +- main.js | 67 +++-- package-lock.json | 4 +- package.json | 2 +- renderer/app.js | 661 ++++++++++++++++++++++++++++++++------------ renderer/i18n.js | 67 ++++- renderer/index.html | 81 +++--- renderer/styles.css | 236 ++++++++++++++-- tests/i18n.test.js | 36 +++ tests/ui-smoke.js | 155 ++++++++++- 10 files changed, 1039 insertions(+), 272 deletions(-) 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 `