perf(accounts): event delegation + in-place card updates

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.
This commit is contained in:
Administrator 2026-04-19 22:49:11 +02:00
parent 00a46dee2e
commit 9c679bd442

View File

@ -2738,6 +2738,57 @@ function getCredentialLabel(name, account) {
return 'Keine Zugangsdaten'; 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 `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
${statusLabel}
</span>
<div class="account-card-actions">
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div>
</div>`;
}
// 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() { function renderAccounts() {
const container = document.getElementById('accountsList'); const container = document.getElementById('accountsList');
if (!container) return; if (!container) return;
@ -2753,10 +2804,10 @@ function renderAccounts() {
<p>Keine Accounts vorhanden</p> <p>Keine Accounts vorhanden</p>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span> <span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
</div>`; </div>`;
if (!_accountListenersBound) bindAccountListeners(container);
return; return;
} }
// Group by hoster for drag reorder sections
const byHoster = {}; const byHoster = {};
for (const { name, account } of allAccounts) { for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = []; if (!byHoster[name]) byHoster[name] = [];
@ -2769,126 +2820,102 @@ function renderAccounts() {
if (!accounts || accounts.length === 0) continue; if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}"> html += `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`; <div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => { accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, 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 += `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
${statusLabel}
</span>
<div class="account-card-actions">
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div>
</div>`;
});
html += '</div>'; html += '</div>';
} }
container.innerHTML = html; container.innerHTML = html;
// Wire up buttons if (!_accountListenersBound) bindAccountListeners(container);
container.querySelectorAll('[data-account-toggle]').forEach(btn => {
btn.addEventListener('click', () => toggleAccount(btn.dataset.accountToggle));
});
container.querySelectorAll('[data-account-edit]').forEach(btn => {
btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit));
});
container.querySelectorAll('[data-account-delete]').forEach(btn => {
btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete));
});
container.querySelectorAll('[data-account-check]').forEach(btn => {
btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck));
});
// Drag-and-drop reorder within each hoster group
setupAccountDragReorder(container);
} }
function setupAccountDragReorder(container) { // 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; let draggedCard = null;
container.querySelectorAll('.account-card[draggable]').forEach(card => { container.addEventListener('dragstart', (e) => {
card.addEventListener('dragstart', (e) => { const card = e.target.closest('.account-card[draggable]');
draggedCard = card; if (!card) return;
card.classList.add('dragging'); draggedCard = card;
e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging');
}); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
card.addEventListener('dragend', () => { });
if (draggedCard) draggedCard.classList.remove('dragging'); container.addEventListener('dragend', () => {
draggedCard = null; if (draggedCard) draggedCard.classList.remove('dragging');
container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); draggedCard = null;
}); container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
card.addEventListener('dragover', (e) => { });
e.preventDefault(); container.addEventListener('dragover', (e) => {
if (!draggedCard || draggedCard === card) return; const card = e.target.closest('.account-card[draggable]');
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; if (!card || !draggedCard || draggedCard === card) return;
e.dataTransfer.dropEffect = 'move'; if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
const rect = card.getBoundingClientRect(); e.preventDefault();
const midY = rect.top + rect.height / 2; if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
card.classList.toggle('drag-over-above', e.clientY < midY); const rect = card.getBoundingClientRect();
card.classList.toggle('drag-over-below', e.clientY >= midY); const midY = rect.top + rect.height / 2;
}); card.classList.toggle('drag-over-above', e.clientY < midY);
card.addEventListener('dragleave', () => { card.classList.toggle('drag-over-below', e.clientY >= midY);
card.classList.remove('drag-over-above', 'drag-over-below'); });
}); container.addEventListener('dragleave', (e) => {
card.addEventListener('drop', async (e) => { const card = e.target.closest('.account-card[draggable]');
e.preventDefault(); if (card) card.classList.remove('drag-over-above', 'drag-over-below');
card.classList.remove('drag-over-above', 'drag-over-below'); });
if (!draggedCard || draggedCard === card) return; container.addEventListener('drop', (e) => {
const hosterName = card.dataset.accountHoster; const card = e.target.closest('.account-card[draggable]');
if (draggedCard.dataset.accountHoster !== hosterName) return; 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 draggedId = draggedCard.dataset.accountId;
const targetId = card.dataset.accountId; const targetId = card.dataset.accountId;
const accounts = config.hosters[hosterName]; const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return; if (!Array.isArray(accounts)) return;
const fromIdx = accounts.findIndex(a => a.id === draggedId); const fromIdx = accounts.findIndex(a => a.id === draggedId);
const toIdx = accounts.findIndex(a => a.id === targetId); if (fromIdx < 0) return;
if (fromIdx < 0 || toIdx < 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 // Move the DOM node in place — no full re-render.
const [moved] = accounts.splice(fromIdx, 1); if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Save and re-render // The Primär / Fallback #N badges just changed for the whole group.
await window.api.saveConfig({ hosters: config.hosters }); for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id);
config = await window.api.getConfig();
renderAccounts(); // 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) { async function toggleAccount(accountId) {
const found = findAccountById(accountId); const found = findAccountById(accountId);
if (!found) return; 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; found.account.enabled = !found.account.enabled;
syncSelectedUploadHosters(); 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(); renderHosterSummary();
try { await window.api.saveConfig({ hosters: config.hosters }); } catch {} window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
} }
async function checkSingleAccount(accountId) { async function checkSingleAccount(accountId) {
@ -2897,7 +2924,7 @@ async function checkSingleAccount(accountId) {
if (!found) return; if (!found) return;
healthCheckRunning = true; healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: '' }; accountStatuses[accountId] = { status: 'checking', message: '' };
renderAccounts(); updateAccountCard(accountId);
try { try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
const rows = result && Array.isArray(result.results) ? result.results : []; const rows = result && Array.isArray(result.results) ? result.results : [];
@ -2908,7 +2935,7 @@ async function checkSingleAccount(accountId) {
} finally { } finally {
healthCheckRunning = false; healthCheckRunning = false;
} }
renderAccounts(); updateAccountCard(accountId);
} }
function getCredsFieldsHtml(authType, account) { function getCredsFieldsHtml(authType, account) {