From 9c679bd4423f03486444d13403ec283ff72efbef Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 22:49:11 +0200 Subject: [PATCH] perf(accounts): event delegation + in-place card updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Accounts view rebuilt the whole list on every enable/disable/ check/reorder. Each render destroyed and recreated four click listeners plus five drag listeners per card (20 accounts = 180 listeners cycled per click), then ran an IPC getConfig round-trip on top. Typing-fast enable/disable toggles felt sludgy. - Single delegated click handler on the accounts container. - Single delegated set of drag/drop handlers (one per event type, not per card). - Listeners are bound once on first render, never rebound. - updateAccountCard(accountId) swaps just the one affected card's DOM node when its state changes. toggleAccount / checkSingleAccount use that instead of calling renderAccounts. - Drag-and-drop reorder moves the DOM node in place and re-renders only the priority badges of the affected group — no container rebuild, no getConfig refetch. --- renderer/app.js | 233 +++++++++++++++++++++++++++--------------------- 1 file changed, 130 insertions(+), 103 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index f6f4c76..1e874cf 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2738,6 +2738,57 @@ function getCredentialLabel(name, account) { return 'Keine Zugangsdaten'; } +const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; + +function _buildAccountCardHtml(name, account, idx) { + const isDisabled = account.enabled === false; + const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; + const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft'); + const statusClass = isDisabled ? 'disabled' : st.status; + const credLabel = getCredentialLabel(name, account); + const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; + const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; + + return ` +
+ + + + +
`; +} + +// Replace only the one card for `accountId` instead of re-rendering the whole +// container. Runs on enable/disable, single health check, priority-badge bumps +// after a reorder — anywhere we only change one card's state. +function updateAccountCard(accountId) { + const container = document.getElementById('accountsList'); + if (!container) return; + const found = findAccountById(accountId); + if (!found) return; + const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`); + if (!card) return; + const accounts = config.hosters[found.name] || []; + const idx = accounts.findIndex(a => a.id === accountId); + if (idx < 0) return; + const tmp = document.createElement('div'); + tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); + card.replaceWith(tmp.firstElementChild); +} + +let _accountListenersBound = false; + function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; @@ -2753,10 +2804,10 @@ function renderAccounts() {

Keine Accounts vorhanden

Klicke auf "Account hinzufügen", um einen Hoster einzurichten. `; + if (!_accountListenersBound) bindAccountListeners(container); return; } - // Group by hoster for drag reorder sections const byHoster = {}; for (const { name, account } of allAccounts) { if (!byHoster[name]) byHoster[name] = []; @@ -2769,126 +2820,102 @@ function renderAccounts() { if (!accounts || accounts.length === 0) continue; html += `
`; - accounts.forEach((account, idx) => { - const isDisabled = account.enabled === false; - const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; - const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; - const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft'); - const statusClass = isDisabled ? 'disabled' : st.status; - const credLabel = getCredentialLabel(name, account); - const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; - const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; - - html += ` - `; - }); + accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); }); html += '
'; } container.innerHTML = html; - // Wire up buttons - container.querySelectorAll('[data-account-toggle]').forEach(btn => { - btn.addEventListener('click', () => toggleAccount(btn.dataset.accountToggle)); - }); - container.querySelectorAll('[data-account-edit]').forEach(btn => { - btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit)); - }); - container.querySelectorAll('[data-account-delete]').forEach(btn => { - btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete)); - }); - container.querySelectorAll('[data-account-check]').forEach(btn => { - btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck)); - }); - - // Drag-and-drop reorder within each hoster group - setupAccountDragReorder(container); + if (!_accountListenersBound) bindAccountListeners(container); } -function setupAccountDragReorder(container) { +// Single set of delegated listeners on the accounts container. Bound once on +// the first render and reused for every subsequent in-place update / card +// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners +// per render — with 20 accounts that's 180 listener create/destroy cycles on +// every enable/disable click. +function bindAccountListeners(container) { + _accountListenersBound = true; + container.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); + if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); + if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); + if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck); + }); + let draggedCard = null; - container.querySelectorAll('.account-card[draggable]').forEach(card => { - card.addEventListener('dragstart', (e) => { - draggedCard = card; - card.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - }); - card.addEventListener('dragend', () => { - if (draggedCard) draggedCard.classList.remove('dragging'); - draggedCard = null; - container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); - }); - card.addEventListener('dragover', (e) => { - e.preventDefault(); - if (!draggedCard || draggedCard === card) return; - if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; - e.dataTransfer.dropEffect = 'move'; - const rect = card.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - card.classList.toggle('drag-over-above', e.clientY < midY); - card.classList.toggle('drag-over-below', e.clientY >= midY); - }); - card.addEventListener('dragleave', () => { - card.classList.remove('drag-over-above', 'drag-over-below'); - }); - card.addEventListener('drop', async (e) => { - e.preventDefault(); - card.classList.remove('drag-over-above', 'drag-over-below'); - if (!draggedCard || draggedCard === card) return; - const hosterName = card.dataset.accountHoster; - if (draggedCard.dataset.accountHoster !== hosterName) return; + container.addEventListener('dragstart', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card) return; + draggedCard = card; + card.classList.add('dragging'); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + }); + container.addEventListener('dragend', () => { + if (draggedCard) draggedCard.classList.remove('dragging'); + draggedCard = null; + container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); + }); + container.addEventListener('dragover', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card || !draggedCard || draggedCard === card) return; + if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const rect = card.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + card.classList.toggle('drag-over-above', e.clientY < midY); + card.classList.toggle('drag-over-below', e.clientY >= midY); + }); + container.addEventListener('dragleave', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (card) card.classList.remove('drag-over-above', 'drag-over-below'); + }); + container.addEventListener('drop', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card || !draggedCard || draggedCard === card) return; + e.preventDefault(); + card.classList.remove('drag-over-above', 'drag-over-below'); + const hosterName = card.dataset.accountHoster; + if (draggedCard.dataset.accountHoster !== hosterName) return; - const draggedId = draggedCard.dataset.accountId; - const targetId = card.dataset.accountId; - const accounts = config.hosters[hosterName]; - if (!Array.isArray(accounts)) return; + const draggedId = draggedCard.dataset.accountId; + const targetId = card.dataset.accountId; + const accounts = config.hosters[hosterName]; + if (!Array.isArray(accounts)) return; - const fromIdx = accounts.findIndex(a => a.id === draggedId); - const toIdx = accounts.findIndex(a => a.id === targetId); - if (fromIdx < 0 || toIdx < 0) return; + const fromIdx = accounts.findIndex(a => a.id === draggedId); + if (fromIdx < 0) return; + const [moved] = accounts.splice(fromIdx, 1); + const rect = card.getBoundingClientRect(); + const insertBefore = e.clientY < rect.top + rect.height / 2; + const newToIdx = accounts.findIndex(a => a.id === targetId); + accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); - // Move account in array - const [moved] = accounts.splice(fromIdx, 1); - const rect = card.getBoundingClientRect(); - const insertBefore = e.clientY < rect.top + rect.height / 2; - const newToIdx = accounts.findIndex(a => a.id === targetId); - accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); + // Move the DOM node in place — no full re-render. + if (insertBefore) card.before(draggedCard); else card.after(draggedCard); - // Save and re-render - await window.api.saveConfig({ hosters: config.hosters }); - config = await window.api.getConfig(); - renderAccounts(); - }); + // The Primär / Fallback #N badges just changed for the whole group. + for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id); + + // Persist in the background. saveConfig is idempotent; we don't need to + // await here or re-fetch — our in-memory config is already the truth. + window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); }); } async function toggleAccount(accountId) { const found = findAccountById(accountId); if (!found) return; - // Flip in place; re-render immediately so the UI responds before the - // disk write completes. The saveConfig is fire-and-forget-ish here because - // we already know the new state locally — a full getConfig round-trip - // after every toggle made rapid enable/disable clicks feel laggy. found.account.enabled = !found.account.enabled; syncSelectedUploadHosters(); - renderAccounts(); + // In-place: swap only the one affected card. No full re-render, no IPC + // refetch, no flicker. Rapid click-toggles now feel instant even with 50 + // accounts in the list. + updateAccountCard(accountId); renderHosterSummary(); - try { await window.api.saveConfig({ hosters: config.hosters }); } catch {} + window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); } async function checkSingleAccount(accountId) { @@ -2897,7 +2924,7 @@ async function checkSingleAccount(accountId) { if (!found) return; healthCheckRunning = true; accountStatuses[accountId] = { status: 'checking', message: '' }; - renderAccounts(); + updateAccountCard(accountId); try { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const rows = result && Array.isArray(result.results) ? result.results : []; @@ -2908,7 +2935,7 @@ async function checkSingleAccount(accountId) { } finally { healthCheckRunning = false; } - renderAccounts(); + updateAccountCard(accountId); } function getCredsFieldsHtml(authType, account) {