diff --git a/allstarr/wwwroot/js/dashboard-data.js b/allstarr/wwwroot/js/dashboard-data.js index 29417af..dea25c7 100644 --- a/allstarr/wwwroot/js/dashboard-data.js +++ b/allstarr/wwwroot/js/dashboard-data.js @@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {}; let setCurrentConfigState = () => {}; let syncConfigUiExtras = () => {}; let loadScrobblingConfig = () => {}; +let injectedPlaylistRequestToken = 0; let jellyfinPlaylistRequestToken = 0; async function fetchStatus() { @@ -39,10 +40,20 @@ async function fetchStatus() { } async function fetchPlaylists(silent = false) { + const requestToken = ++injectedPlaylistRequestToken; + try { const data = await API.fetchPlaylists(); + if (requestToken !== injectedPlaylistRequestToken) { + return; + } + UI.updatePlaylistsUI(data); } catch (error) { + if (requestToken !== injectedPlaylistRequestToken) { + return; + } + if (!silent) { console.error("Failed to fetch playlists:", error); showToast("Failed to fetch playlists", "error"); diff --git a/allstarr/wwwroot/js/ui.js b/allstarr/wwwroot/js/ui.js index 8c2e09b..8cf4e84 100644 --- a/allstarr/wwwroot/js/ui.js +++ b/allstarr/wwwroot/js/ui.js @@ -5,6 +5,7 @@ import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js"; let rowMenuHandlersBound = false; let tableRowHandlersBound = false; const expandedInjectedPlaylistDetails = new Set(); +let openInjectedPlaylistMenuKey = null; function bindRowMenuHandlers() { if (rowMenuHandlersBound) { @@ -57,8 +58,16 @@ function closeAllRowMenus(exceptId = null) { document.querySelectorAll(".row-actions-menu.open").forEach((menu) => { if (!exceptId || menu.id !== exceptId) { menu.classList.remove("open"); + const trigger = menu.parentElement?.querySelector?.(".menu-trigger"); + if (trigger) { + trigger.setAttribute("aria-expanded", "false"); + } } }); + + if (!exceptId) { + openInjectedPlaylistMenuKey = null; + } } function closeRowMenu(event, menuId) { @@ -69,6 +78,13 @@ function closeRowMenu(event, menuId) { const menu = document.getElementById(menuId); if (menu) { menu.classList.remove("open"); + const trigger = menu.parentElement?.querySelector?.(".menu-trigger"); + if (trigger) { + trigger.setAttribute("aria-expanded", "false"); + } + if (menu.dataset.menuKey) { + openInjectedPlaylistMenuKey = null; + } } } @@ -85,6 +101,14 @@ function toggleRowMenu(event, menuId) { const isOpen = menu.classList.contains("open"); closeAllRowMenus(menuId); menu.classList.toggle("open", !isOpen); + const trigger = menu.parentElement?.querySelector?.(".menu-trigger"); + if (trigger) { + trigger.setAttribute("aria-expanded", String(!isOpen)); + } + + if (menu.dataset.menuKey) { + openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey; + } } function toggleDetailsRow(event, detailsRowId) { @@ -224,6 +248,275 @@ function getPlaylistStatusSummary(playlist) { }; } +function syncElementAttributes(target, source) { + if (!target || !source) { + return; + } + + const sourceAttributes = new Map( + Array.from(source.attributes || []).map((attribute) => [ + attribute.name, + attribute.value, + ]), + ); + + Array.from(target.attributes || []).forEach((attribute) => { + if (!sourceAttributes.has(attribute.name)) { + target.removeAttribute(attribute.name); + } + }); + + sourceAttributes.forEach((value, name) => { + target.setAttribute(name, value); + }); +} + +function syncPlaylistRowActionsWrap(existingWrap, nextWrap) { + if (!existingWrap || !nextWrap) { + return; + } + + syncElementAttributes(existingWrap, nextWrap); + + const activeElement = document.activeElement; + let focusTarget = null; + + if (activeElement && existingWrap.contains(activeElement)) { + if (activeElement.classList.contains("menu-trigger")) { + focusTarget = { type: "trigger" }; + } else if (activeElement.tagName === "BUTTON") { + focusTarget = { + type: "menu-item", + action: activeElement.getAttribute("data-action") || "", + text: activeElement.textContent || "", + }; + } + } + + const existingTrigger = existingWrap.querySelector(".menu-trigger"); + const nextTrigger = nextWrap.querySelector(".menu-trigger"); + if (existingTrigger && nextTrigger) { + syncElementAttributes(existingTrigger, nextTrigger); + existingTrigger.textContent = nextTrigger.textContent; + } else if (nextTrigger && !existingTrigger) { + existingWrap.prepend(nextTrigger.cloneNode(true)); + } else if (existingTrigger && !nextTrigger) { + existingTrigger.remove(); + } + + const existingMenu = existingWrap.querySelector(".row-actions-menu"); + const nextMenu = nextWrap.querySelector(".row-actions-menu"); + if (existingMenu && nextMenu) { + syncElementAttributes(existingMenu, nextMenu); + existingMenu.replaceChildren( + ...Array.from(nextMenu.children).map((child) => child.cloneNode(true)), + ); + } else if (nextMenu && !existingMenu) { + existingWrap.append(nextMenu.cloneNode(true)); + } else if (existingMenu && !nextMenu) { + existingMenu.remove(); + } + + if (!focusTarget) { + return; + } + + if (focusTarget.type === "trigger") { + existingWrap.querySelector(".menu-trigger")?.focus(); + return; + } + + const matchingButton = + Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find( + (button) => + (button.getAttribute("data-action") || "") === focusTarget.action && + button.textContent === focusTarget.text, + ) || + Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find( + (button) => + (button.getAttribute("data-action") || "") === focusTarget.action, + ); + + matchingButton?.focus(); +} + +function syncPlaylistControlsCell( + existingControlsCell, + nextControlsCell, + preserveOpenMenu = false, +) { + if (!existingControlsCell || !nextControlsCell) { + return; + } + + syncElementAttributes(existingControlsCell, nextControlsCell); + + if (!preserveOpenMenu) { + existingControlsCell.innerHTML = nextControlsCell.innerHTML; + return; + } + + const existingDetailsTrigger = + existingControlsCell.querySelector(".details-trigger"); + const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger"); + const existingWrap = existingControlsCell.querySelector(".row-actions-wrap"); + const nextWrap = nextControlsCell.querySelector(".row-actions-wrap"); + + if ( + !existingDetailsTrigger || + !nextDetailsTrigger || + !existingWrap || + !nextWrap + ) { + existingControlsCell.innerHTML = nextControlsCell.innerHTML; + return; + } + + syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger); + existingDetailsTrigger.textContent = nextDetailsTrigger.textContent; + syncPlaylistRowActionsWrap(existingWrap, nextWrap); +} + +function syncPlaylistMainRow( + existingMainRow, + nextMainRow, + preserveOpenMenu = false, +) { + if (!existingMainRow || !nextMainRow) { + return; + } + + syncElementAttributes(existingMainRow, nextMainRow); + + const nextCells = Array.from(nextMainRow.children); + const existingCells = Array.from(existingMainRow.children); + + if (!preserveOpenMenu || nextCells.length !== existingCells.length) { + existingMainRow.innerHTML = nextMainRow.innerHTML; + return; + } + + nextCells.forEach((nextCell, index) => { + const existingCell = existingCells[index]; + if (!existingCell) { + existingMainRow.append(nextCell.cloneNode(true)); + return; + } + + if (index === nextCells.length - 1) { + syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu); + return; + } + + existingCell.replaceWith(nextCell.cloneNode(true)); + }); + + while (existingMainRow.children.length > nextCells.length) { + existingMainRow.lastElementChild?.remove(); + } +} + +function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) { + if (!existingDetailsRow || !nextDetailsRow) { + return; + } + + syncElementAttributes(existingDetailsRow, nextDetailsRow); + existingDetailsRow.innerHTML = nextDetailsRow.innerHTML; +} + +function renderPlaylistRowPairMarkup(playlist, index) { + const summary = getPlaylistStatusSummary(playlist); + const detailsRowId = `playlist-details-${index}`; + const menuId = `playlist-menu-${index}`; + const detailsKey = `${playlist.id || playlist.name || index}`; + const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey); + const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey; + const syncSchedule = playlist.syncSchedule || "0 8 * * *"; + const escapedPlaylistName = escapeHtml(playlist.name); + const escapedSyncSchedule = escapeHtml(syncSchedule); + const escapedDetailsKey = escapeHtml(detailsKey); + + const breakdownBadges = [ + `${summary.localCount} Local`, + `${summary.externalMatched} External`, + ]; + + if (summary.externalMissing > 0) { + breakdownBadges.push( + `${summary.externalMissing} Missing`, + ); + } + + return ` +