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';
}
function renderAccounts() {
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
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>`;
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) => {
function _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 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}`;
html += `
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">
@ -2797,59 +2767,116 @@ function renderAccounts() {
<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() {
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>';
}
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) => {
container.addEventListener('dragstart', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card) return;
draggedCard = card;
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');
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) => {
e.preventDefault();
if (!draggedCard || draggedCard === card) return;
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.dataTransfer.dropEffect = 'move';
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);
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over-above', 'drag-over-below');
container.addEventListener('dragleave', (e) => {
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();
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;
@ -2859,36 +2886,36 @@ function setupAccountDragReorder(container) {
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;
// Move account in array
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);
// Save and re-render
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
renderAccounts();
});
// Move the DOM node in place — no full re-render.
if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
// 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) {