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,48 +2738,18 @@ function getCredentialLabel(name, account) {
return 'Keine Zugangsdaten'; return 'Keine Zugangsdaten';
} }
function renderAccounts() { const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const allAccounts = getAllAccountsFlat(); function _buildAccountCardHtml(name, account, idx) {
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
if (allAccounts.length === 0) {
container.innerHTML = `
<div class="accounts-empty">
<p>Keine Accounts vorhanden</p>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
</div>`;
return;
}
// Group by hoster for drag reorder sections
const byHoster = {};
for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = [];
byHoster[name].push(account);
}
let html = '';
for (const name of HOSTERS) {
const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => {
const isDisabled = account.enabled === false; const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; 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' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft');
const statusClass = isDisabled ? 'disabled' : st.status; const statusClass = isDisabled ? 'disabled' : st.status;
const credLabel = getCredentialLabel(name, account); const credLabel = getCredentialLabel(name, account);
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
html += ` return `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true"> <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-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info"> <div class="account-card-info">
@ -2797,59 +2767,116 @@ function renderAccounts() {
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button> <button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div> </div>
</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() {
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const allAccounts = getAllAccountsFlat();
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
if (allAccounts.length === 0) {
container.innerHTML = `
<div class="accounts-empty">
<p>Keine Accounts vorhanden</p>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
</div>`;
if (!_accountListenersBound) bindAccountListeners(container);
return;
}
const byHoster = {};
for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = [];
byHoster[name].push(account);
}
let html = '';
for (const name of HOSTERS) {
const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); });
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]');
if (!card) return;
draggedCard = card; draggedCard = card;
card.classList.add('dragging'); card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}); });
card.addEventListener('dragend', () => { container.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging'); if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null; draggedCard = null;
container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
}); });
card.addEventListener('dragover', (e) => { container.addEventListener('dragover', (e) => {
e.preventDefault(); const card = e.target.closest('.account-card[draggable]');
if (!draggedCard || draggedCard === card) return; if (!card || !draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
e.dataTransfer.dropEffect = 'move'; e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect(); const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2; const midY = rect.top + rect.height / 2;
card.classList.toggle('drag-over-above', e.clientY < midY); card.classList.toggle('drag-over-above', e.clientY < midY);
card.classList.toggle('drag-over-below', e.clientY >= midY); card.classList.toggle('drag-over-below', e.clientY >= midY);
}); });
card.addEventListener('dragleave', () => { container.addEventListener('dragleave', (e) => {
card.classList.remove('drag-over-above', 'drag-over-below'); const card = e.target.closest('.account-card[draggable]');
if (card) card.classList.remove('drag-over-above', 'drag-over-below');
}); });
card.addEventListener('drop', async (e) => { container.addEventListener('drop', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card || !draggedCard || draggedCard === card) return;
e.preventDefault(); e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below'); card.classList.remove('drag-over-above', 'drag-over-below');
if (!draggedCard || draggedCard === card) return;
const hosterName = card.dataset.accountHoster; const hosterName = card.dataset.accountHoster;
if (draggedCard.dataset.accountHoster !== hosterName) return; if (draggedCard.dataset.accountHoster !== hosterName) return;
@ -2859,36 +2886,36 @@ function setupAccountDragReorder(container) {
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;
// Move account in array
const [moved] = accounts.splice(fromIdx, 1); const [moved] = accounts.splice(fromIdx, 1);
const rect = card.getBoundingClientRect(); const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2; const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId); const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Save and re-render // Move the DOM node in place — no full re-render.
await window.api.saveConfig({ hosters: config.hosters }); if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
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) { 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) {