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:
parent
00a46dee2e
commit
9c679bd442
191
renderer/app.js
191
renderer/app.js
@ -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">☰</div>
|
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">☰</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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user