From c54cf6b22e98122c7c37aea06131945d2540c792 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:52:19 +0200 Subject: [PATCH] Harden renderer modal and upload races --- renderer/app.js | 639 +++++++++++++++++++++----------------------- renderer/i18n.js | 20 +- renderer/index.html | 22 +- renderer/styles.css | 4 + tests/i18n.test.js | 25 ++ tests/ui-smoke.js | 285 +++++++++++++++++++- 6 files changed, 640 insertions(+), 355 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index fb97a70..25dbdb4 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -385,11 +385,142 @@ let historySidebarFilter = 'all'; let _knownUpdateInfo = null; let _updateCheckBusy = false; let _updateInstallBusy = false; -let _updateDialogReturnFocus = null; -let _updateDialogInertState = []; let _startupAutoResumeController = null; let _startupAutoResumeCanceled = false; +const modalController = (() => { + const stack = []; + const baselineInert = new Map(); + const focusSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + const resolveOverlay = value => typeof value === 'string' ? document.getElementById(value) : value; + const getDialog = overlay => overlay?.querySelector('[role="dialog"]') || overlay; + const isFocusable = element => { + if (!(element instanceof HTMLElement) || element.hidden || element.matches(':disabled')) return false; + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0; + }; + const getFocusable = overlay => Array.from(getDialog(overlay)?.querySelectorAll(focusSelector) || []).filter(isFocusable); + const resolveFocusTarget = (entry, value) => { + const candidate = typeof value === 'function' + ? value() + : typeof value === 'string' + ? entry.overlay.querySelector(value) || document.querySelector(value) + : value; + return isFocusable(candidate) ? candidate : null; + }; + const syncIsolation = () => { + const top = stack.at(-1); + if (!top) { + baselineInert.forEach((inert, element) => { + if (element.isConnected) element.inert = inert; + }); + baselineInert.clear(); + return; + } + Array.from(document.body.children).forEach(element => { + if (!baselineInert.has(element)) baselineInert.set(element, element.inert); + element.inert = element !== top.overlay; + }); + }; + const focusEntry = entry => { + if (!entry || stack.at(-1) !== entry) return; + const dialog = getDialog(entry.overlay); + const target = resolveFocusTarget(entry, entry.options.initialFocus) || getFocusable(entry.overlay)[0] || dialog; + if (target instanceof HTMLElement && !target.matches(':disabled')) target.focus(); + }; + const open = (value, options = {}) => { + const overlay = resolveOverlay(value); + if (!overlay) return false; + let entry = stack.find(item => item.overlay === overlay); + if (entry) { + entry.options = { ...entry.options, ...options }; + stack.splice(stack.indexOf(entry), 1); + stack.push(entry); + } else { + entry = { + overlay, + options, + returnFocus: options.returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null) + }; + stack.push(entry); + } + overlay.style.display = options.display || 'flex'; + overlay.setAttribute('aria-hidden', 'false'); + overlay.inert = false; + syncIsolation(); + if (!getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry); + requestAnimationFrame(() => { + if (stack.at(-1) === entry && !getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry); + }); + return true; + }; + const close = (value, options = {}) => { + const overlay = resolveOverlay(value); + const index = stack.findIndex(item => item.overlay === overlay); + if (index < 0) { + if (overlay) { + overlay.style.display = 'none'; + overlay.setAttribute('aria-hidden', 'true'); + } + return false; + } + const wasTop = index === stack.length - 1; + const [entry] = stack.splice(index, 1); + overlay.style.display = 'none'; + overlay.setAttribute('aria-hidden', 'true'); + syncIsolation(); + if (wasTop && options.restoreFocus !== false) { + const fallback = resolveFocusTarget(entry, options.fallbackFocus ?? entry.options.fallbackFocus); + const target = resolveFocusTarget(entry, entry.returnFocus) || fallback; + if (target) target.focus(); + else if (stack.length > 0) focusEntry(stack.at(-1)); + } + return true; + }; + const isOpen = value => { + const overlay = resolveOverlay(value); + return Boolean(overlay && stack.some(item => item.overlay === overlay)); + }; + const handleKeydown = event => { + const entry = stack.at(-1); + if (!entry) return; + const dialog = getDialog(entry.overlay); + if (!dialog) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + entry.options.onEscape?.(); + return; + } + if (event.key !== 'Tab') return; + const focusable = getFocusable(entry.overlay); + event.stopImmediatePropagation(); + if (focusable.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if (!dialog.contains(document.activeElement) || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } + }; + const handleClick = event => { + const entry = stack.at(-1); + if (!entry || event.target !== entry.overlay || typeof entry.options.onBackdrop !== 'function') return; + event.preventDefault(); + event.stopImmediatePropagation(); + entry.options.onBackdrop(); + }; + + document.addEventListener('keydown', handleKeydown, true); + document.addEventListener('click', handleClick, true); + return { open, close, isOpen }; +})(); + // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; let _recentSeqCounter = 0; @@ -1140,12 +1271,16 @@ function renderHosterModal() { function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); - document.getElementById('hosterModal').style.display = 'flex'; + modalController.open('hosterModal', { + initialFocus: '#cancelHosterModalBtn', + fallbackFocus: '#addFilesBtn', + onEscape: cancelHosterModal, + onBackdrop: cancelHosterModal + }); } function closeHosterModal() { - const modal = document.getElementById('hosterModal'); - if (modal) modal.style.display = 'none'; + modalController.close('hosterModal', { fallbackFocus: '#addFilesBtn' }); } async function applyHosterSelection() { @@ -1173,7 +1308,7 @@ async function applyHosterSelection() { updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files - document.getElementById('hosterModal').style.display = 'none'; + closeHosterModal(); } function cancelHosterModal() { @@ -2788,21 +2923,8 @@ document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { - if (_isUpdateDialogVisible()) return; - const accountModal = document.getElementById('accountModal'); - if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') { - const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) { - e.preventDefault(); - (e.shiftKey ? last : first).focus(); - } - } if (e.key === 'Escape') { hideContextMenu(); - cancelHosterModal(); - if (accountModal && accountModal.style.display !== 'none') closeAccountModal(); } if (e.target instanceof window.Element && e.target.closest('input, textarea, select')) return; const activeView = document.querySelector('.view.active'); @@ -2953,11 +3075,27 @@ async function persistSourceCleanupRevocations(preparation) { if (Array.isArray(preparation?.revokedHosters) && preparation.revokedHosters.length > 0) { sourceCleanupRevocationPending = true; } - if (!sourceCleanupRevocationPending) return; - queuePersistThrottle.cancel(); - await persistQueueStateNow(); - await flushConfigWrites(); - sourceCleanupRevocationPending = false; + if (!sourceCleanupRevocationPending) return false; + await _persistQueueSnapshotBeforeUploadStart(); + return true; +} + +async function persistQueueBeforeUploadStart(preparation) { + if (await persistSourceCleanupRevocations(preparation)) return; + await _persistQueueSnapshotBeforeUploadStart(); +} + +async function _persistQueueSnapshotBeforeUploadStart() { + try { + queuePersistThrottle.cancel(); + await persistQueueStateNow(); + await flushConfigWrites(); + sourceCleanupRevocationPending = false; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + error.queuePersistenceFailure = true; + throw error; + } } async function completeSourceCleanupFinalization(data) { @@ -3044,7 +3182,7 @@ async function startUpload(opts) { updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); - await persistSourceCleanupRevocations(cleanupPreparation); + await persistQueueBeforeUploadStart(cleanupPreparation); const uploadPayload = { hosters, @@ -3069,7 +3207,8 @@ async function startUpload(opts) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen'); + const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen'; + await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen'); } } @@ -3104,13 +3243,14 @@ async function startSelectedUpload(explicitJobs) { renderQueueTable(); let result = null; try { - await persistSourceCleanupRevocations(cleanupPreparation); + await persistQueueBeforeUploadStart(cleanupPreparation); result = await window.api.addJobsToBatch({ jobs: addable.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { - showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', err)); + const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden'; + showCopyToast(formatLocalizedError(prefix, err)); return; } @@ -3128,21 +3268,20 @@ async function startSelectedUpload(explicitJobs) { } persistQueueStateSoon(); const added = Number(result && result.added) || 0; - // Use ASCII-only toast text here to avoid encoding artifacts on some systems. const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0; const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds) ? result.alreadyInBatchJobIds.length : Math.max(0, addable.length - added - skipped); const toastParts = []; - if (added > 0) toastParts.push(`${added} hinzugefuegt`); - if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); - if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); + if (added > 0) toastParts.push(localizeUiText(`${added} hinzugefügt`)); + if (alreadyInBatch > 0) toastParts.push(localizeUiText(`${alreadyInBatch} bereits im Batch`)); + if (skipped > 0) toastParts.push(localizeUiText(`${skipped} ohne gültigen Account`)); if (result && result.error) { showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', result.error)); } else if (toastParts.length > 0) { showCopyToast(`Jobs: ${toastParts.join(', ')}`); } else { - showCopyToast('Keine Jobs hinzugefuegt'); + showCopyToast('Keine Jobs hinzugefügt'); } return; } @@ -3168,7 +3307,7 @@ async function startSelectedUpload(explicitJobs) { updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); - await persistSourceCleanupRevocations(cleanupPreparation); + await persistQueueBeforeUploadStart(cleanupPreparation); const uploadPayload = { hosters, @@ -3192,7 +3331,8 @@ async function startSelectedUpload(explicitJobs) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen'); + const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen'; + await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen'); } } @@ -3228,26 +3368,29 @@ function handleProgress(data) { } } function _handleProgressImpl(data) { - let job = data.jobId ? _jobIndexById.get(data.jobId) : null; - if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); + const hasJobId = data.jobId !== undefined && data.jobId !== null && data.jobId !== ''; + let job = hasJobId ? _jobIndexById.get(data.jobId) : null; + if (hasJobId && !job) return; + if (!hasJobId && 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' + const candidates = queueJobs.filter(candidate => + candidate.fileName === data.fileName && + candidate.hoster === data.hoster && + candidate.status !== 'done' && + candidate.status !== 'skipped' ); + if (candidates.length > 1) return; + job = candidates.length === 1 ? candidates[0] : null; 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; - } + if (['done', 'error', 'aborted', 'skipped'].includes(data.status)) return; + if (data.uploadId && _deletedJobIds.has(data.uploadId)) return; job = { - id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: 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, @@ -3575,6 +3718,9 @@ function _handleStatsImpl(data) { } // --- Per-job log modal --- +let _jobLogGeneration = 0; +let _jobLogJobId = null; + async function showJobLogModal() { const selectedJobs = _getVisibleSelectedQueueJobs(); if (selectedJobs.length === 0) return; @@ -3582,20 +3728,28 @@ async function showJobLogModal() { // make sense here. const job = selectedJobs[0]; const jobId = job.id; + const generation = ++_jobLogGeneration; + _jobLogJobId = jobId; const modal = document.getElementById('jobLogModal'); const titleEl = document.getElementById('jobLogTitle'); const bodyEl = document.getElementById('jobLogBody'); if (!modal || !titleEl || !bodyEl) return; titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log'; - bodyEl.textContent = 'Lade…'; - modal.style.display = 'flex'; + bodyEl.textContent = localizeUiText('Lade…'); + modalController.open(modal, { + initialFocus: '#closeJobLogBtn', + fallbackFocus: '#addFilesBtn', + onEscape: hideJobLogModal, + onBackdrop: hideJobLogModal + }); let entries = []; try { entries = await window.api.getJobLog(jobId); } catch {} + if (generation !== _jobLogGeneration || _jobLogJobId !== jobId || !modalController.isOpen(modal) || _jobIndexById.get(jobId) !== job) return; if (!Array.isArray(entries) || entries.length === 0) { - bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'; + bodyEl.textContent = localizeUiText('Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'); return; } @@ -3619,18 +3773,19 @@ async function showJobLogModal() { ? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n') : ''; const summary = [ - `Hoster: ${job.hoster || '–'}`, - `Account: ${getAccountLabel(job) || job.accountId || '–'}`, - `Versuch: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`, - job.error ? `Fehler: ${job.error}` : '', - details ? `Diagnose:\n${details}` : '' + `${localizeUiText('Hoster')}: ${job.hoster || '–'}`, + `${localizeUiText('Account')}: ${getAccountLabel(job) || job.accountId || '–'}`, + `${localizeUiText('Versuch')}: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`, + job.error ? `${localizeUiText('Fehler')}: ${job.error}` : '', + details ? `${localizeUiText('Diagnose')}:\n${details}` : '' ].filter(Boolean).join('\n'); bodyEl.textContent = `${summary}\n\n${entries.map(fmt).join('\n')}`; } function hideJobLogModal() { - const m = document.getElementById('jobLogModal'); - if (m) m.style.display = 'none'; + _jobLogGeneration++; + _jobLogJobId = null; + modalController.close('jobLogModal', { fallbackFocus: '#addFilesBtn' }); } async function copyJobLogToClipboard() { @@ -4114,16 +4269,23 @@ function _setRollingUploadMetric(id, value) { const direction = numericValue > previousValue ? 'up' : 'down'; const previousText = element.getAttribute('aria-label') || previousValue.toLocaleString(getUiLocale()); + element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel())); + element.dataset.numericValue = String(numericValue); + element.setAttribute('aria-label', nextText); + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + const settled = document.createElement('span'); + settled.textContent = nextText; + element.replaceChildren(settled); + element.dataset.direction = 'none'; + return; + } const outgoing = document.createElement('span'); const incoming = document.createElement('span'); outgoing.textContent = previousText; incoming.textContent = nextText; outgoing.className = 'upload-rolling-outgoing'; incoming.className = 'upload-rolling-incoming'; - element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel())); - element.dataset.numericValue = String(numericValue); element.dataset.direction = direction; - element.setAttribute('aria-label', nextText); element.replaceChildren(outgoing, incoming); const distance = direction === 'up' ? -1 : 1; @@ -4244,37 +4406,13 @@ function renderHealthCheckResults(_results) { } let _appAlertResolve = null; -let _appAlertReturnFocus = null; -let _appAlertInertState = []; - -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; - const returnFocus = _appAlertReturnFocus; - _appAlertReturnFocus = null; - if (returnFocus?.isConnected) returnFocus.focus(); + modalController.close(modal); if (resolve) resolve(result); } @@ -4287,7 +4425,6 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT 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); @@ -4296,10 +4433,11 @@ function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelT cancel.hidden = !showCancel; alternate.textContent = localizeUiText(alternateText); alternate.hidden = !alternateText; - modal.style.display = 'flex'; - modal.setAttribute('aria-hidden', 'false'); - _setAppAlertBackgroundInert(true); - (showCancel ? cancel : confirm).focus(); + modalController.open(modal, { + initialFocus: () => showCancel ? cancel : confirm, + onEscape: () => closeAppAlert(false), + onBackdrop: () => closeAppAlert(false) + }); return new Promise(resolve => { _appAlertResolve = resolve; }); } @@ -4330,27 +4468,6 @@ function setupAppAlertListeners() { alternate.addEventListener('click', () => closeAppAlert('alternate')); cancel.addEventListener('click', () => closeAppAlert(false)); close.addEventListener('click', () => closeAppAlert(false)); - modal.addEventListener('click', event => { - if (event.target === modal) closeAppAlert(false); - }); - document.addEventListener('keydown', event => { - if (modal.style.display !== 'flex') return; - const focusable = [...modal.querySelectorAll('button:not([disabled]):not([hidden])')]; - if (event.key === 'Tab' && focusable.length) { - const first = focusable[0]; - const last = focusable.at(-1); - if ((!event.shiftKey && document.activeElement === last) || (event.shiftKey && document.activeElement === first)) { - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - closeAppAlert(false); - } - }, true); } async function executeHealthCheck(hosters, _mode, generations) { @@ -6079,10 +6196,7 @@ function wireCredentialVisibilityButtons(container) { }); } -let _accountModalReturnFocus = null; - function openAccountModal(editAccountId) { - _accountModalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; editingAccountId = editAccountId || null; _resetAccountModalState(); const modal = document.getElementById('accountModal'); @@ -6126,26 +6240,19 @@ function openAccountModal(editAccountId) { _wireCredFieldInvalidation(); - modal.style.display = 'flex'; - requestAnimationFrame(() => { - const firstControl = editingAccountId - ? document.getElementById('accField_label') - : hosterSelect; - if (firstControl) firstControl.focus(); + modalController.open(modal, { + initialFocus: () => editingAccountId ? document.getElementById('accField_label') : hosterSelect, + fallbackFocus: '#addAccountBtn', + onEscape: closeAccountModal, + onBackdrop: closeAccountModal }); } function closeAccountModal() { - document.getElementById('accountModal').style.display = 'none'; + modalController.close('accountModal', { fallbackFocus: '#addAccountBtn' }); _hideOtpField(); editingAccountId = null; _resetAccountModalState(); - const returnFocus = _accountModalReturnFocus; - _accountModalReturnFocus = null; - const focusTarget = returnFocus && returnFocus.isConnected - ? returnFocus - : document.getElementById('addAccountBtn'); - if (focusTarget) focusTarget.focus(); } function openDeleteAccountModal(accountId) { @@ -6155,11 +6262,16 @@ function openDeleteAccountModal(accountId) { const msg = document.getElementById('deleteAccountMessage'); msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`; modal.dataset.accountId = accountId; - modal.style.display = 'flex'; + modalController.open(modal, { + initialFocus: '#cancelDeleteBtn', + fallbackFocus: '#addAccountBtn', + onEscape: closeDeleteModal, + onBackdrop: closeDeleteModal + }); } function closeDeleteModal() { - document.getElementById('deleteAccountModal').style.display = 'none'; + modalController.close('deleteAccountModal', { fallbackFocus: '#addAccountBtn' }); } async function deleteAccount(accountId) { @@ -6175,12 +6287,12 @@ async function deleteAccount(accountId) { // saveConfig is async — close the modal immediately so the UI feels // responsive instead of waiting for the atomic write + safeStorage encrypt. // The in-memory config already reflects the delete; the IPC just persists it. - closeDeleteModal(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); + closeDeleteModal(); // Fire-and-forget the persist. The earlier `await getConfig()` round-trip // was redundant (we already have the truth in memory) and was the main // source of perceived lag on add/delete. @@ -6492,54 +6604,21 @@ function syncHistoryClearAction() { syncDataActionState(); } -let historyClearReturnFocus = null; -let historyClearInertState = []; - -function setHistoryClearBackgroundInert(active) { - const modal = document.getElementById('historyClearModal'); - if (!modal) return; - if (active) { - if (historyClearInertState.length > 0) return; - historyClearInertState = Array.from(document.body.children) - .filter(element => element !== modal) - .map(element => ({ element, inert: element.inert })); - historyClearInertState.forEach(({ element }) => { element.inert = true; }); - return; - } - historyClearInertState.forEach(({ element, inert }) => { - if (element.isConnected) element.inert = inert; - }); - historyClearInertState = []; -} - -function getHistoryClearFocusable() { - const modal = document.getElementById('historyClearModal'); - if (!modal) return []; - return Array.from(modal.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')) - .filter(element => !element.hidden && window.getComputedStyle(element).display !== 'none' && window.getComputedStyle(element).visibility !== 'hidden'); -} - function closeHistoryClearModal() { - const modal = document.getElementById('historyClearModal'); - if (!modal) return; - modal.style.display = 'none'; - modal.setAttribute('aria-hidden', 'true'); - setHistoryClearBackgroundInert(false); - const returnFocus = historyClearReturnFocus; - historyClearReturnFocus = null; - if (returnFocus?.isConnected && !returnFocus.disabled) returnFocus.focus(); - else document.getElementById('clearHistoryBtn')?.focus(); + modalController.close('historyClearModal', { fallbackFocus: '#clearHistoryBtn' }); } function openHistoryClearModal() { const button = document.getElementById('clearHistoryBtn'); const modal = document.getElementById('historyClearModal'); if (!modal || !button || button.disabled) return; - historyClearReturnFocus = button; - modal.style.display = 'flex'; - modal.setAttribute('aria-hidden', 'false'); - setHistoryClearBackgroundInert(true); - document.getElementById('cancelHistoryClearBtn')?.focus(); + modalController.open(modal, { + initialFocus: '#cancelHistoryClearBtn', + returnFocus: button, + fallbackFocus: '#clearHistoryBtn', + onEscape: closeHistoryClearModal, + onBackdrop: closeHistoryClearModal + }); } async function confirmHistoryClear() { @@ -6982,7 +7061,6 @@ function sortHistoryRows(rows) { } let closePreparationPromise = null; -let closePreparationInertState = []; let closePreparationOverlayState = null; let closePreparationGeneration = 0; let activeClosePreparationAttempt = null; @@ -7003,27 +7081,31 @@ function setClosePreparationUi(active) { if (active) { if (closePreparationOverlayState) return; closePreparationOverlayState = { - display: overlay.style.display, + wasOpen: modalController.isOpen(overlay), message: message.innerHTML, cancelDisplay: cancelButton.style.display }; - closePreparationInertState = Array.from(document.body.children) - .filter(element => element !== overlay && 'inert' in element) - .map(element => ({ element, inert: element.inert })); - closePreparationInertState.forEach(({ element }) => { element.inert = true; }); message.textContent = 'Einstellungen werden gespeichert…'; cancelButton.style.display = 'none'; - overlay.style.display = 'flex'; + modalController.open(overlay, { + initialFocus: () => overlay.querySelector('[role="dialog"]'), + onEscape: () => {} + }); return; } - closePreparationInertState.forEach(({ element, inert }) => { - if (element.isConnected) element.inert = inert; - }); - closePreparationInertState = []; - overlay.style.display = closePreparationOverlayState ? closePreparationOverlayState.display : 'none'; - message.innerHTML = closePreparationOverlayState ? closePreparationOverlayState.message : ''; - cancelButton.style.display = closePreparationOverlayState ? closePreparationOverlayState.cancelDisplay : ''; + const previousState = closePreparationOverlayState; + message.innerHTML = previousState ? previousState.message : ''; + cancelButton.style.display = previousState ? previousState.cancelDisplay : ''; closePreparationOverlayState = null; + if (previousState?.wasOpen) { + modalController.open(overlay, { + initialFocus: '#cancelShutdownBtn', + onEscape: cancelShutdownCountdown + }); + requestAnimationFrame(() => cancelButton.focus()); + } else { + modalController.close(overlay); + } } function isCurrentClosePreparation(generation, attempt) { @@ -7212,41 +7294,12 @@ function setupListeners() { document.getElementById('confirmHistoryClearBtn').addEventListener('click', confirmHistoryClear); document.getElementById('cancelHistoryClearBtn').addEventListener('click', closeHistoryClearModal); document.getElementById('closeHistoryClearModalBtn').addEventListener('click', closeHistoryClearModal); - document.getElementById('historyClearModal').addEventListener('click', event => { - if (event.target.id === 'historyClearModal') closeHistoryClearModal(); - }); - document.addEventListener('keydown', event => { - const modal = document.getElementById('historyClearModal'); - if (modal?.style.display !== 'flex') return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - closeHistoryClearModal(); - return; - } - if (event.key !== 'Tab') return; - const focusable = getHistoryClearFocusable(); - if (!focusable.length) { - event.preventDefault(); - modal.querySelector('[role="dialog"]')?.focus(); - return; - } - const first = focusable[0]; - const last = focusable.at(-1); - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }, true); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); document.getElementById('exportSessionReportBtn').addEventListener('click', async () => { const format = await showAppChoice({ title: 'Sitzungsbericht exportieren', message: 'Welches Format möchtest du speichern?', confirmText: 'CSV', alternateText: 'JSON' }); if (format === false) return; const result = await window.api.exportSessionReport(format === 'alternate' ? 'json' : 'csv'); - if (result?.ok) showCopyToast(`Sitzungsbericht mit ${result.totalRows} Uploads exportiert`); + if (result?.ok) showCopyToast(result.totalRows === 1 ? 'Sitzungsbericht mit 1 Upload exportiert' : `Sitzungsbericht mit ${result.totalRows} Uploads exportiert`); else if (!result?.canceled) await showAppAlert(result?.error || 'Sitzungsbericht konnte nicht exportiert werden.'); }); document.getElementById('queueSearchInput').addEventListener('input', applyQueueDetailFilters); @@ -7376,13 +7429,7 @@ function setupListeners() { setupColumnResizing(); // Shutdown cancel - document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { - await window.api.cancelShutdown(); - if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } - const overlay = document.getElementById('shutdownOverlay'); - overlay.style.display = 'none'; - overlay.setAttribute('aria-hidden', 'true'); - }); + document.getElementById('cancelShutdownBtn').addEventListener('click', cancelShutdownCountdown); // Click on empty area in queue → deselect all document.getElementById('upload-view').addEventListener('click', (e) => { @@ -7404,19 +7451,11 @@ function setupListeners() { 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) => { _invalidateAccountSubmit(); @@ -7436,26 +7475,14 @@ function setupListeners() { const accountId = modal.dataset.accountId; if (accountId) deleteAccount(accountId); }); - document.getElementById('deleteAccountModal').addEventListener('click', (e) => { - if (e.target.id === 'deleteAccountModal') closeDeleteModal(); - }); - // Job log modal document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal); document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal); document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard); - document.getElementById('jobLogModal')?.addEventListener('click', (e) => { - if (e.target.id === 'jobLogModal') hideJobLogModal(); - }); - document.getElementById('headerUpdateBtn')?.addEventListener('click', requestUpdateCheck); document.getElementById('installUpdateBtn')?.addEventListener('click', installKnownUpdate); document.getElementById('dismissUpdateBtn')?.addEventListener('click', closeUpdateDialog); document.getElementById('updateCloseBtn')?.addEventListener('click', closeUpdateDialog); - document.getElementById('updateBanner')?.addEventListener('click', (event) => { - if (event.target.id === 'updateBanner') closeUpdateDialog(); - }); - document.addEventListener('keydown', _handleUpdateDialogKeydown, true); _syncHeaderUpdateState(); } @@ -7566,6 +7593,7 @@ function handleUpdateProgress(data) { _updateInstallBusy = false; _setUpdateDialogBusy(false); _setUpdateProgress(0, 'Update fehlgeschlagen'); + _setUpdateProgressVisible(false); if (message) { message.hidden = false; message.textContent = formatLocalizedError('Update fehlgeschlagen', progress.error); @@ -7579,98 +7607,21 @@ function handleUpdateProgress(data) { } function _isUpdateDialogVisible() { - const overlay = document.getElementById('updateBanner'); - return Boolean(overlay && overlay.style.display !== 'none' && overlay.getAttribute('aria-hidden') !== 'true'); -} - -function _setUpdateBackgroundInert(active) { - const overlay = document.getElementById('updateBanner'); - if (!overlay) return; - if (active) { - if (_updateDialogInertState.length > 0) return; - _updateDialogInertState = Array.from(document.body.children) - .filter(element => element !== overlay && 'inert' in element) - .map(element => ({ element, inert: element.inert })); - _updateDialogInertState.forEach(({ element }) => { element.inert = true; }); - return; - } - _updateDialogInertState.forEach(({ element, inert }) => { - if (element.isConnected) element.inert = inert; - }); - _updateDialogInertState = []; -} - -function _getUpdateDialogFocusable() { - const dialog = document.querySelector('#updateBanner .update-dialog'); - if (!dialog) return []; - return Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')) - .filter(element => !element.hidden && element.getClientRects().length > 0 && window.getComputedStyle(element).visibility !== 'hidden'); -} - -function _focusUpdateDialog() { - const dialog = document.querySelector('#updateBanner .update-dialog'); - if (!dialog) return; - const target = _getUpdateDialogFocusable()[0] || dialog; - target.focus(); -} - -function _handleUpdateDialogKeydown(event) { - if (!_isUpdateDialogVisible()) return; - const dialog = document.querySelector('#updateBanner .update-dialog'); - if (!dialog) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - if (!_updateInstallBusy) closeUpdateDialog(); - return; - } - if (event.key !== 'Tab') { - if (!dialog.contains(event.target)) { - event.preventDefault(); - event.stopImmediatePropagation(); - _focusUpdateDialog(); - } - return; - } - const focusable = _getUpdateDialogFocusable(); - if (focusable.length === 0) { - event.preventDefault(); - event.stopImmediatePropagation(); - dialog.focus(); - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const activeElement = document.activeElement; - if (!dialog.contains(activeElement) || (!event.shiftKey && activeElement === last) || (event.shiftKey && activeElement === first)) { - event.preventDefault(); - event.stopImmediatePropagation(); - (event.shiftKey ? last : first).focus(); - } + return modalController.isOpen('updateBanner'); } function _setUpdateDialogVisible(visible) { const overlay = document.getElementById('updateBanner'); if (!overlay) return; if (visible) { - const wasVisible = _isUpdateDialogVisible(); - if (!wasVisible) { - _updateDialogReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; - _setUpdateBackgroundInert(true); - } - overlay.style.display = 'flex'; - overlay.setAttribute('aria-hidden', 'false'); - requestAnimationFrame(() => { - const dialog = overlay.querySelector('.update-dialog'); - if (_isUpdateDialogVisible() && dialog && !dialog.contains(document.activeElement)) _focusUpdateDialog(); + modalController.open(overlay, { + initialFocus: '#updateCloseBtn', + fallbackFocus: '#headerUpdateBtn', + onEscape: closeUpdateDialog, + onBackdrop: closeUpdateDialog }); } else { - overlay.style.display = 'none'; - overlay.setAttribute('aria-hidden', 'true'); - _setUpdateBackgroundInert(false); - const returnFocus = _updateDialogReturnFocus; - _updateDialogReturnFocus = null; - if (returnFocus && returnFocus.isConnected && typeof returnFocus.focus === 'function') returnFocus.focus(); + modalController.close(overlay, { fallbackFocus: '#headerUpdateBtn' }); } } @@ -7691,6 +7642,7 @@ function _setUpdateDialogBusy(busy) { } function _setUpdateProgress(percent, text) { + _setUpdateProgressVisible(true); const value = Math.max(0, Math.min(100, Math.round(Number(percent) || 0))); const progressText = document.getElementById('updateProgressText'); const progressBar = document.getElementById('updateProgressBar'); @@ -7703,6 +7655,13 @@ function _setUpdateProgress(percent, text) { } } +function _setUpdateProgressVisible(visible) { + const progress = document.querySelector('#updateBanner .update-progress'); + const text = document.getElementById('updateProgressText'); + if (progress) progress.hidden = !visible; + if (text) text.hidden = !visible; +} + async function installKnownUpdate() { if (_updateInstallBusy) return; if (!_knownUpdateInfo || !_knownUpdateInfo.available) { @@ -7725,24 +7684,38 @@ async function installKnownUpdate() { // --- Shutdown --- let shutdownCountdownInterval = null; + +async function cancelShutdownCountdown() { + await window.api.cancelShutdown(); + if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); + shutdownCountdownInterval = null; + modalController.close('shutdownOverlay'); +} + function handleShutdownCountdown(data) { const overlay = document.getElementById('shutdownOverlay'); const msgEl = document.getElementById('shutdownMessage'); - const secEl = document.getElementById('shutdownSeconds'); - overlay.style.display = 'flex'; + if (!overlay || !msgEl) return; const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' }; let remaining = data.seconds || 60; - secEl.textContent = remaining; - msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`); - overlay.setAttribute('aria-hidden', 'false'); + const render = () => { + msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`); + }; + render(); + modalController.open(overlay, { + initialFocus: '#cancelShutdownBtn', + onEscape: cancelShutdownCountdown + }); if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = setInterval(() => { remaining--; - secEl.textContent = remaining; - msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`); - if (remaining <= 0) { clearInterval(shutdownCountdownInterval); } + render(); + if (remaining <= 0) { + clearInterval(shutdownCountdownInterval); + shutdownCountdownInterval = null; + } }, 1000); } diff --git a/renderer/i18n.js b/renderer/i18n.js index bb5c6ac..56b2e48 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -41,6 +41,7 @@ ['Hinweis', 'Notice'], ['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'], ['Hoster', 'Host'], + ['Versuch', 'Attempt'], ['Importieren', 'Import'], ['In Zwischenablage', 'To clipboard'], ['In diesem Lauf hochgeladen:', 'Uploaded during this run:'], @@ -416,7 +417,7 @@ ['Keine Antwort vom Hoster erhalten', 'No response received from the host'], ['Keine Einträge im Log gefunden', 'No log entries found'], ['Keine Einträge zum Exportieren.', 'No entries to export.'], - ['Keine Jobs hinzugefuegt', 'No jobs added'], + ['Keine Jobs hinzugefügt', 'No jobs added'], ['Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).', 'No log entries for this job. Nothing has happened yet, or entries from a previous batch were already cleared.'], ['Keine passenden Jobs für Retry gefunden.', 'No matching jobs found for retry.'], ['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'], @@ -592,6 +593,8 @@ ['Upload-Status', 'Upload status'], ['Upload-Übersicht', 'Upload overview'], ['Upload-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'], + ['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'], + ['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload'], ['Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON', 'Export history as CSV?\n\nOK = CSV\nCancel = JSON'], ['Verlauf wirklich löschen?', 'Are you sure you want to delete the history?'], ['Verschlüssele und speichere Einstellungen…', 'Encrypting and saving settings…'], @@ -647,7 +650,14 @@ const patterns = target === 'en' ? [ [/^Update v(.+) verfügbar$/, 'Update v$1 available'], + [/^1 unterbrochener Upload kann fortgesetzt werden\.$/, '1 interrupted upload can be resumed.'], + [/^(\d+) unterbrochene Uploads können fortgesetzt werden\.$/, '$1 interrupted uploads can be resumed.'], [/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'], + [/^(\d+) hinzugefügt$/, '$1 added'], + [/^(\d+) bereits im Batch$/, '$1 already in the batch'], + [/^(\d+) ohne gültigen Account$/, '$1 without a valid account'], + [/^Sitzungsbericht mit 1 Upload exportiert$/, 'Session report with 1 upload exported'], + [/^Sitzungsbericht mit (\d+) Uploads exportiert$/, 'Session report with $1 uploads exported'], [/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'], [/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'], [/^Export fehlgeschlagen: (.+)$/, (_, detail) => `Export failed: ${translateErrorDetail(detail)}`], @@ -758,6 +768,14 @@ [/^Import failed: (.+)$/, (_, detail) => `Import fehlgeschlagen: ${translateErrorDetail(detail)}`], [/^Initialization failed: (.+)$/, (_, detail) => `Initialisierung fehlgeschlagen: ${translateErrorDetail(detail)}`], [/^Update v(.+) available$/, 'Update v$1 verfügbar'], + [/^1 interrupted upload can be resumed\.$/, '1 unterbrochener Upload kann fortgesetzt werden.'], + [/^(\d+) interrupted uploads can be resumed\.$/, '$1 unterbrochene Uploads können fortgesetzt werden.'], + [/^Restored queue starts in (.+) s \((.+) jobs\)\.$/, 'Wiederhergestellte Warteschlange startet in $1 s ($2 Jobs).'], + [/^(\d+) added$/, '$1 hinzugefügt'], + [/^(\d+) already in the batch$/, '$1 bereits im Batch'], + [/^(\d+) without a valid account$/, '$1 ohne gültigen Account'], + [/^Session report with 1 upload exported$/, 'Sitzungsbericht mit 1 Upload exportiert'], + [/^Session report with (\d+) uploads exported$/, 'Sitzungsbericht mit $1 Uploads exportiert'], [/^Sleep in (\d+)s\.\.\.$/, 'Ruhezustand in $1s...'], [/^Shut down in (\d+)s\.\.\.$/, 'Herunterfahren in $1s...'], [/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'], diff --git a/renderer/index.html b/renderer/index.html index 510ef29..94b8388 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -148,7 +148,7 @@