feat: animate account sections and align header actions

This commit is contained in:
Sucukdeluxe
2026-08-11 01:03:05 +02:00
parent 52ee5e3b13
commit 203c0846c7
3 changed files with 115 additions and 26 deletions
+25 -20
View File
@@ -4936,12 +4936,13 @@ function _buildHosterSettingsHtml(name) {
const hs = (config.hosterSettings && config.hosterSettings[name]) || {}; const hs = (config.hosterSettings && config.hosterSettings[name]) || {};
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? String(+(hs.maxSpeedKbs / 1024).toFixed(2)) : '0'; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? String(+(hs.maxSpeedKbs / 1024).toFixed(2)) : '0';
return `<div class="account-hoster-settings"> return `<div class="account-hoster-settings">
<div class="account-hoster-settings-header" data-hoster-settings-toggle="${name}"> <div class="account-hoster-settings-header" data-hoster-settings-toggle="${name}" aria-expanded="false">
<span class="panel-arrow">&#9654;</span> <span class="panel-arrow">&#9654;</span>
<span>Upload-Einstellungen</span> <span>Upload-Einstellungen</span>
</div> </div>
<div class="account-hoster-settings-body" style="display:none"> <div class="account-hoster-settings-body account-collapse" aria-hidden="true" inert>
<div class="settings-grid-mini"> <div class="account-collapse-content">
<div class="account-hoster-settings-body-inner settings-grid-mini">
<div class="settings-row"> <div class="settings-row">
<label>Retries</label> <label>Retries</label>
<input type="number" class="hs-input" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500"> <input type="number" class="hs-input" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500">
@@ -4986,6 +4987,7 @@ function _buildHosterSettingsHtml(name) {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>`; </div>`;
} }
@@ -4994,17 +4996,15 @@ function _buildAccountHosterGroupHtml(name, accounts) {
const isOpen = _hosterGroupOpenState(name, summary); const isOpen = _hosterGroupOpenState(name, summary);
const dot = window.AccountStatus.getAccountGroupStatus(summary); const dot = window.AccountStatus.getAccountGroupStatus(summary);
const countLabel = `${summary.ok}/${summary.total}`; const countLabel = `${summary.ok}/${summary.total}`;
const arrow = isOpen ? '&#9660;' : '&#9654;';
let cardsHtml = ''; let cardsHtml = '';
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); }); accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
const bodyStyle = isOpen ? '' : 'style="display:none"';
const lifeStat = _hosterLifetimeStat(name); const lifeStat = _hosterLifetimeStat(name);
const lifeMeta = lifeStat && lifeStat.total > 0 const lifeMeta = lifeStat && lifeStat.total > 0
? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>` ? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>`
: ''; : '';
return `<div class="account-hoster-group" data-hoster-group="${name}"> return `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-header" data-hoster-toggle="${name}"> <div class="account-hoster-group-header" data-hoster-toggle="${name}" aria-expanded="${isOpen}">
<span class="panel-arrow">${arrow}</span> <span class="panel-arrow">&#9654;</span>
<span class="account-status-dot status-${dot}"></span> <span class="account-status-dot status-${dot}"></span>
<span class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</span> <span class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</span>
<span class="account-hoster-group-count">${countLabel}</span> <span class="account-hoster-group-count">${countLabel}</span>
@@ -5012,7 +5012,9 @@ function _buildAccountHosterGroupHtml(name, accounts) {
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''} ${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
${lifeMeta} ${lifeMeta}
</div> </div>
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div> <div class="account-hoster-group-body account-collapse${isOpen ? ' is-open' : ''}" aria-hidden="${!isOpen}" ${isOpen ? '' : 'inert'}>
<div class="account-collapse-content"><div class="account-hoster-group-body-inner">${cardsHtml}</div></div>
</div>
${_buildHosterSettingsHtml(name)} ${_buildHosterSettingsHtml(name)}
</div>`; </div>`;
} }
@@ -5029,18 +5031,25 @@ function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
function _allAccountGroupsOpen() { function _allAccountGroupsOpen() {
const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body'); const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body');
if (!bodies.length) return false; if (!bodies.length) return false;
for (const b of bodies) if (b.style.display === 'none') return false; for (const body of bodies) if (!body.classList.contains('is-open')) return false;
return true; return true;
} }
function _setAccountCollapseOpen(trigger, body, open) {
if (!body) return;
body.classList.toggle('is-open', open);
body.setAttribute('aria-hidden', String(!open));
body.toggleAttribute('inert', !open);
if (trigger) trigger.setAttribute('aria-expanded', String(open));
}
function _setAllAccountGroupsOpen(open) { function _setAllAccountGroupsOpen(open) {
const groups = document.querySelectorAll('#accountsList .account-hoster-group'); const groups = document.querySelectorAll('#accountsList .account-hoster-group');
groups.forEach(group => { groups.forEach(group => {
const name = group.dataset.hosterGroup; const name = group.dataset.hosterGroup;
const body = group.querySelector('.account-hoster-group-body'); const body = group.querySelector('.account-hoster-group-body');
const arrow = group.querySelector('.panel-arrow'); const header = group.querySelector('[data-hoster-toggle]');
if (body) body.style.display = open ? '' : 'none'; _setAccountCollapseOpen(header, body, open);
if (arrow) arrow.innerHTML = open ? '&#9660;' : '&#9654;';
if (name) { if (name) {
const summary = _summarizeHosterGroup(config.hosters[name] || []); const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: open ? 'open' : 'closed', errorsAtClose: summary.error }); _hosterGroupOpenMemory.set(name, { state: open ? 'open' : 'closed', errorsAtClose: summary.error });
@@ -5068,11 +5077,9 @@ function bindAccountListeners(container) {
const name = header.dataset.hosterToggle; const name = header.dataset.hosterToggle;
const group = header.closest('.account-hoster-group'); const group = header.closest('.account-hoster-group');
const body = group && group.querySelector('.account-hoster-group-body'); const body = group && group.querySelector('.account-hoster-group-body');
const arrow = header.querySelector('.panel-arrow');
if (body) { if (body) {
const willOpen = body.style.display === 'none'; const willOpen = !body.classList.contains('is-open');
body.style.display = willOpen ? '' : 'none'; _setAccountCollapseOpen(header, body, willOpen);
if (arrow) arrow.innerHTML = willOpen ? '&#9660;' : '&#9654;';
const summary = _summarizeHosterGroup(config.hosters[name] || []); const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error }); _hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
} }
@@ -5081,11 +5088,9 @@ function bindAccountListeners(container) {
const settingsHeader = e.target.closest('[data-hoster-settings-toggle]'); const settingsHeader = e.target.closest('[data-hoster-settings-toggle]');
if (settingsHeader) { if (settingsHeader) {
const body = settingsHeader.nextElementSibling; const body = settingsHeader.nextElementSibling;
const arrow = settingsHeader.querySelector('.panel-arrow');
if (body) { if (body) {
const isOpen = body.style.display !== 'none'; const willOpen = !body.classList.contains('is-open');
body.style.display = isOpen ? 'none' : ''; _setAccountCollapseOpen(settingsHeader, body, willOpen);
if (arrow) arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
} }
return; return;
} }
+51 -3
View File
@@ -1275,7 +1275,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
.account-hoster-settings-header:hover { background: var(--bg-card-hover); color: var(--text); } .account-hoster-settings-header:hover { background: var(--bg-card-hover); color: var(--text); }
.account-hoster-settings-header .panel-arrow { font-size: 10px; color: var(--text-dim); } .account-hoster-settings-header .panel-arrow { font-size: 10px; color: var(--text-dim); }
.account-hoster-settings-body { padding: 4px 12px 10px; } .account-hoster-settings-body-inner { padding: 4px 12px 10px; }
.settings-hoster-pointer { .settings-hoster-pointer {
margin-top: 12px; margin-top: 12px;
@@ -1374,7 +1374,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; } .batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); } .batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
.batch-cat-list em { color: var(--text-muted); font-style: italic; } .batch-cat-list em { color: var(--text-muted); font-style: italic; }
.account-hoster-group-body { .account-hoster-group-body-inner {
padding: 8px; padding: 8px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -1392,6 +1392,38 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
display: inline-block; display: inline-block;
} }
.account-collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition: grid-template-rows 220ms cubic-bezier(.2, .8, .2, 1), opacity 160ms ease, visibility 0s linear 220ms;
}
.account-collapse.is-open {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition-delay: 0s;
}
.account-collapse-content {
min-height: 0;
overflow: hidden;
}
.account-hoster-group-header .panel-arrow,
.account-hoster-settings-header .panel-arrow {
display: inline-block;
transform-origin: center;
transition: transform 180ms cubic-bezier(.2, .8, .2, 1);
}
.account-hoster-group-header[aria-expanded="true"] .panel-arrow,
.account-hoster-settings-header[aria-expanded="true"] .panel-arrow {
transform: rotate(90deg);
}
.accounts-empty { .accounts-empty {
width: min(520px, 100%); width: min(520px, 100%);
margin: clamp(56px, 12vh, 120px) auto 0; margin: clamp(56px, 12vh, 120px) auto 0;
@@ -1716,6 +1748,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.menu-closing { .menu-closing {
animation-duration: 160ms !important; animation-duration: 160ms !important;
} }
.account-collapse {
transition-duration: 220ms, 160ms, 0s !important;
}
.account-hoster-group-header .panel-arrow,
.account-hoster-settings-header .panel-arrow {
transition-duration: 180ms !important;
}
} }
.online-backup-panel { .online-backup-panel {
@@ -3186,10 +3227,17 @@ input[type="checkbox"] {
} }
.accounts-auto-check { .accounts-auto-check {
min-height: 38px; min-height: 34px;
height: 34px;
padding: 0 12px; padding: 0 12px;
border-radius: 6px; border-radius: 6px;
background: var(--bg-raised); background: var(--bg-raised);
box-sizing: border-box;
}
.accounts-header-actions > .btn {
min-height: 34px;
height: 34px;
} }
.account-health-results { .account-health-results {
+39 -3
View File
@@ -457,6 +457,9 @@ setTimeout(async () => {
const accountsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()'); const accountsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
check('Accounts view and statusbar fit inside the viewport', accountsFrameFit === true); check('Accounts view and statusbar fit inside the viewport', accountsFrameFit === true);
const accountHeaderControlHeights = await wc.executeJavaScript('(() => [document.getElementById("accountsRunHealthCheckBtn"), document.querySelector(".accounts-auto-check"), document.getElementById("addAccountBtn")].map(element => element?.getBoundingClientRect().height || 0))()');
check('Accounts header actions share one rendered height', accountHeaderControlHeights.every(height => height > 0 && Math.abs(height - accountHeaderControlHeights[0]) <= 0.5));
await captureVisual('02-accounts.png'); await captureVisual('02-accounts.png');
const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)'); const accountListValid = await wc.executeJavaScript('Boolean(document.querySelector("#accountsList .accounts-empty") || document.querySelectorAll("#accountsList .account-hoster-group").length)');
@@ -536,6 +539,39 @@ setTimeout(async () => {
check('Empty account state remains contained at 1280x720', emptyAccountsGeometry.emptyVisible && emptyAccountsGeometry.contained); check('Empty account state remains contained at 1280x720', emptyAccountsGeometry.emptyVisible && emptyAccountsGeometry.contained);
await captureVisual('02-accounts-empty-1280x720.png'); await captureVisual('02-accounts-empty-1280x720.png');
const accountCollapseMotion = await wc.executeJavaScript(\`(async () => {
const hoster = HOSTERS[0];
HOSTERS.forEach(name => { config.hosters[name] = []; });
config.hosters[hoster] = [{ id: 'ui-collapse-account', label: 'Animated account', enabled: true, authType: 'login', username: 'animated@example.invalid', password: 'fictional-password' }];
accountStatuses = { 'ui-collapse-account': { status: 'ok', message: 'Bereit' } };
_hosterGroupOpenMemory.set(hoster, { state: 'closed', errorsAtClose: 0 });
renderAccounts();
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const sample = async (trigger, body) => {
const closedStart = body.getBoundingClientRect().height;
trigger.click();
await wait(70);
const opening = body.getBoundingClientRect().height;
const openingState = body.classList.contains('is-open');
const duration = parseFloat(getComputedStyle(body).transitionDuration) * 1000;
await wait(240);
const opened = body.getBoundingClientRect().height;
trigger.click();
await wait(70);
const closing = body.getBoundingClientRect().height;
const closingState = !body.classList.contains('is-open');
await wait(240);
const closedEnd = body.getBoundingClientRect().height;
return { closedStart, opening, opened, closing, closedEnd, openingState, closingState, duration };
};
const hosterMotion = await sample(document.querySelector('[data-hoster-toggle]'), document.querySelector('.account-hoster-group-body'));
const settingsMotion = await sample(document.querySelector('[data-hoster-settings-toggle]'), document.querySelector('.account-hoster-settings-body'));
return { hosterMotion, settingsMotion };
})()\`);
const hasSmoothAccountCollapse = motion => motion.closedStart <= 1 && motion.opening > 1 && motion.opening < motion.opened - 1 && motion.closing > 1 && motion.closing < motion.opened - 1 && motion.closedEnd <= 1 && motion.openingState && motion.closingState && motion.duration >= 180;
check('Hoster groups visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.hosterMotion));
check('Hoster upload settings visibly animate while opening and closing', hasSmoothAccountCollapse(accountCollapseMotion.settingsMotion));
const tallAccountGroupGeometry = await wc.executeJavaScript(\`(() => { const tallAccountGroupGeometry = await wc.executeJavaScript(\`(() => {
const hoster = HOSTERS[0]; const hoster = HOSTERS[0];
HOSTERS.forEach(name => { config.hosters[name] = []; }); HOSTERS.forEach(name => { config.hosters[name] = []; });
@@ -582,7 +618,7 @@ setTimeout(async () => {
}); });
renderAccounts(); renderAccounts();
document.querySelectorAll('[data-hoster-toggle]').forEach(header => { document.querySelectorAll('[data-hoster-toggle]').forEach(header => {
if (header.nextElementSibling?.style.display === 'none') header.click(); if (!header.nextElementSibling?.classList.contains('is-open')) header.click();
}); });
[...document.querySelectorAll('[data-hoster-settings-toggle]')].slice(0, 3).forEach(header => header.click()); [...document.querySelectorAll('[data-hoster-settings-toggle]')].slice(0, 3).forEach(header => header.click());
const list = document.getElementById('accountsList'); const list = document.getElementById('accountsList');
@@ -590,8 +626,8 @@ setTimeout(async () => {
if (list) list.scrollTop = list.scrollHeight; if (list) list.scrollTop = list.scrollHeight;
return { return {
groupCount: groups.length, groupCount: groups.length,
openGroupCount: groups.filter(group => group.querySelector('.account-hoster-group-body')?.style.display !== 'none').length, openGroupCount: groups.filter(group => group.querySelector('.account-hoster-group-body')?.classList.contains('is-open')).length,
openSettingsCount: groups.filter(group => group.querySelector('.account-hoster-settings-body')?.style.display !== 'none').length, openSettingsCount: groups.filter(group => group.querySelector('.account-hoster-settings-body')?.classList.contains('is-open')).length,
listClientHeight: list?.clientHeight || 0, listClientHeight: list?.clientHeight || 0,
listScrollHeight: list?.scrollHeight || 0, listScrollHeight: list?.scrollHeight || 0,
listScrollTop: list?.scrollTop || 0, listScrollTop: list?.scrollTop || 0,