From 3cfc4375995050b836e4e5efff62eb5d1ec8f4e2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 8 Mar 2026 19:45:02 +0300 Subject: [PATCH] Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse - Dialog modals: scale+fade entrance/exit with animated backdrop - Tab panels: fade-in with subtle slide on switch - Settings sections: content slide-down on expand - Browser grid/list items: staggered cascade entrance animation - Connection banner: slide-in + attention pulse on disconnect - Accessibility: prefers-reduced-motion disables all animations Co-Authored-By: Claude Opus 4.6 --- media_server/static/css/styles.css | 78 ++++++++++++++++++++++++++++- media_server/static/js/browser.js | 8 +-- media_server/static/js/callbacks.js | 2 +- media_server/static/js/core.js | 10 +++- media_server/static/js/links.js | 2 +- media_server/static/js/scripts.js | 4 +- 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 983e22d..1ddeb78 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -479,10 +479,17 @@ h1 { [data-tab-content] { display: none; + opacity: 0; } [data-tab-content].active { display: block; + animation: tabFadeIn 0.25s ease-out forwards; +} + +@keyframes tabFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } } @media (max-width: 600px) { @@ -1162,7 +1169,7 @@ button:disabled { border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); - transition: transform 0.2s; + transition: transform 0.3s ease; flex-shrink: 0; } @@ -1177,6 +1184,12 @@ button:disabled { .settings-section-content { padding: 0 1rem 1rem; overflow-x: auto; + animation: settingsExpand 0.3s ease-out; +} + +@keyframes settingsExpand { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } } .settings-section-description { @@ -1660,6 +1673,11 @@ dialog { width: 90%; margin: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); + animation: dialogIn 0.25s ease-out; +} + +dialog.dialog-closing { + animation: dialogOut 0.2s ease-in forwards; } /* Ensure dialogs are hidden until explicitly opened */ @@ -1669,6 +1687,31 @@ dialog:not([open]) { dialog::backdrop { background: rgba(0, 0, 0, 0.8); + animation: backdropIn 0.25s ease-out; +} + +dialog.dialog-closing::backdrop { + animation: backdropOut 0.2s ease-in forwards; +} + +@keyframes dialogIn { + from { opacity: 0; transform: scale(0.9) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes dialogOut { + from { opacity: 1; transform: scale(1) translateY(0); } + to { opacity: 0; transform: scale(0.9) translateY(10px); } +} + +@keyframes backdropIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes backdropOut { + from { opacity: 1; } + to { opacity: 0; } } .confirm-dialog { @@ -2582,6 +2625,8 @@ footer .separator { border-radius: 4px; cursor: pointer; transition: all 0.15s; + animation: itemFadeIn 0.3s ease-out backwards; + animation-delay: calc(var(--item-index, 0) * 20ms); } .browser-list-item:hover { @@ -2709,6 +2754,13 @@ footer .separator { align-items: center; gap: 0.5rem; position: relative; + animation: itemFadeIn 0.3s ease-out backwards; + animation-delay: calc(var(--item-index, 0) * 30ms); +} + +@keyframes itemFadeIn { + from { opacity: 0; transform: translateY(8px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } } .browser-item:hover { @@ -3113,6 +3165,11 @@ footer .separator { font-weight: 500; text-align: center; transition: transform 0.3s ease; + animation: bannerSlideIn 0.4s ease-out; +} + +.connection-banner:not(.hidden) { + animation: bannerSlideIn 0.4s ease-out, bannerPulse 2s ease-in-out 0.4s 2; } .connection-banner.hidden { @@ -3120,6 +3177,16 @@ footer .separator { pointer-events: none; } +@keyframes bannerSlideIn { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +@keyframes bannerPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + .connection-banner-btn { padding: 4px 14px; background: rgba(255, 255, 255, 0.2); @@ -3237,3 +3304,12 @@ body.mini-player-visible footer { -webkit-overflow-scrolling: touch; } } + +/* Accessibility: reduce motion for users who prefer it */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/media_server/static/js/browser.js b/media_server/static/js/browser.js index 299010a..8234c0f 100644 --- a/media_server/static/js/browser.js +++ b/media_server/static/js/browser.js @@ -250,9 +250,10 @@ function renderBrowserList(items, container) { return; } - items.forEach(item => { + items.forEach((item, idx) => { const row = document.createElement('div'); row.className = 'browser-list-item'; + row.style.setProperty('--item-index', Math.min(idx, 20)); row.dataset.name = item.name; row.dataset.type = item.type; @@ -343,9 +344,10 @@ function renderBrowserGrid(items, container) { return; } - items.forEach(item => { + items.forEach((item, idx) => { const div = document.createElement('div'); div.className = 'browser-item'; + div.style.setProperty('--item-index', Math.min(idx, 20)); div.dataset.name = item.name; div.dataset.type = item.type; @@ -870,7 +872,7 @@ function showManageFoldersDialog() { } function closeFolderDialog() { - document.getElementById('folderDialog').close(); + closeDialog(document.getElementById('folderDialog')); } async function saveFolder(event) { diff --git a/media_server/static/js/callbacks.js b/media_server/static/js/callbacks.js index 729d9bc..d0ef940 100644 --- a/media_server/static/js/callbacks.js +++ b/media_server/static/js/callbacks.js @@ -124,7 +124,7 @@ async function closeCallbackDialog() { const dialog = document.getElementById('callbackDialog'); callbackFormDirty = false; - dialog.close(); + closeDialog(dialog); document.body.classList.remove('dialog-open'); } diff --git a/media_server/static/js/core.js b/media_server/static/js/core.js index 13e594a..6f5a846 100644 --- a/media_server/static/js/core.js +++ b/media_server/static/js/core.js @@ -331,6 +331,14 @@ function showToast(message, type = 'success') { }, TOAST_DURATION_MS); } +function closeDialog(dialog) { + dialog.classList.add('dialog-closing'); + dialog.addEventListener('animationend', () => { + dialog.classList.remove('dialog-closing'); + dialog.close(); + }, { once: true }); +} + function showConfirm(message) { return new Promise((resolve) => { const dialog = document.getElementById('confirmDialog'); @@ -344,7 +352,7 @@ function showConfirm(message) { btnCancel.removeEventListener('click', onCancel); btnConfirm.removeEventListener('click', onConfirm); dialog.removeEventListener('close', onClose); - dialog.close(); + closeDialog(dialog); } function onCancel() { cleanup(); resolve(false); } diff --git a/media_server/static/js/links.js b/media_server/static/js/links.js index 0500e0a..bccfe3e 100644 --- a/media_server/static/js/links.js +++ b/media_server/static/js/links.js @@ -329,7 +329,7 @@ async function closeLinkDialog() { const dialog = document.getElementById('linkDialog'); linkFormDirty = false; - dialog.close(); + closeDialog(dialog); document.body.classList.remove('dialog-open'); } diff --git a/media_server/static/js/scripts.js b/media_server/static/js/scripts.js index 5d96ba7..5f028ac 100644 --- a/media_server/static/js/scripts.js +++ b/media_server/static/js/scripts.js @@ -283,7 +283,7 @@ async function closeScriptDialog() { const dialog = document.getElementById('scriptDialog'); scriptFormDirty = false; - dialog.close(); + closeDialog(dialog); document.body.classList.remove('dialog-open'); } @@ -375,7 +375,7 @@ async function deleteScriptConfirm(scriptName) { function closeExecutionDialog() { const dialog = document.getElementById('executionDialog'); - dialog.close(); + closeDialog(dialog); document.body.classList.remove('dialog-open'); }