]> ###2026.09.05f - Moved the controls to a better anchor: #asga-bar, Modern App Store's own toolbar (search box, sort, view toggle), confirmed to persist across both stock and Modern views. Sits as an inline item next to the existing toolbar controls instead of as a block above the card grid. - The previous grid-derived anchor is kept only as a fallback for installs without Modern App Store, where no shared toolbar exists to use. ###2026.09.05e - Fixed UX bug seen on a real install: the controls bar was an inline span that got pulled into the card grid's own CSS Grid/Flexbox sizing rules, causing it to overlap the first card instead of sitting above the grid. Rebuilt as a block-level bar with grid-column/flex-basis overrides so it always spans the full row regardless of the parent's layout model, plus a background and padding so it reads as a toolbar rather than debris. ###2026.09.05d - Fixed stock-view breakage found on a real running install: this build of Community Applications no longer has #CategoryLine at all, and the pin toggle is now .pinnedCard (presence = pinned) rather than .pinPopup with an "unpinned" class. Confirmed via live devtools inspection. - Replaced the hardcoded-ID anchor with a self-deriving one: controls are now anchored to the card grid's own parent (found via the confirmed .ca_holder selector) instead of a named element that can disappear in a future CA rebuild without warning. ###2026.09.05c - Removed the standalone "Bulk Unpin & Hide Settings" page. It only ever contained a placeholder comment and did nothing, but Menu="Apps" made it show up as its own tab in the Apps navigation next to CA's Home / Installed Apps / Previous Apps - clutter with no function. The plugin works entirely through inject.js; there's nothing to configure. ###2026.09.05b - Added support for ghzgod/unraid-modern-appstore as an alternate view. Selectors confirmed via live inspection: cards are .asga-tile, the pin toggle is .asga-btn.asga-pin (pinned = has class asga-pinned), installed apps carry .asga-btn-installed, and the toolbar anchor is #asga-bar. The script now picks the right adapter based on Modern App Store's own #asga-toggle-cb checkbox state, re-syncing when it's flipped. - Section tracking (Pinned Apps) is shared across both views since it's driven by CA's own sidebar regardless of which grid is rendering. ###2026.09.05 - Confirmed the "Installed" indicator via live inspection: cards use .installedCardBackground / .installedCardText. Selector updated from a guess to the verified markup. - Fixed section detection: was checking for an unverified 'active'/'selected' class on the nav item; now tracked via the actual sectionMenu click event. - Fixed control placement: was appended inside #CategoryLine (at risk of being wiped when CA replaces that element's contents); now inserted as a sibling after it. ###2026.01.04 - Refactored DOM selectors based on exact CA single-page app structure. - Replaced polling with MutationObserver on .mainArea. - Added confirm dialog for destructive actions and configurable INSTALLED_INDICATOR_SELECTOR constant. /usr/local/emhttp/plugins/ca.bulk.unpin/inject.js (function () { 'use strict'; /* ===================================================================== * Two view adapters. CONFIRMED via live devtools inspection on both * stock Community Applications and ghzgod/unraid-modern-appstore. * Section tracking (Pinned Apps) is shared - CA's sidebar/.sectionMenu * click drives it regardless of which grid renderer draws the cards. * ===================================================================== */ const STOCK = { name: 'stock', card: '.ca_holder', pinToggle: '.pinnedCard', // Presence of .pinnedCard IS the pinned signal on this build - no // separate "unpinned" class to check (unlike the older .pinPopup // markup this replaced). isPinned: (el) => !!el, installed: '.installedCardText, .installedCardBackground', }; const MODERN = { name: 'modern', card: '.asga-tile', pinToggle: '.asga-btn.asga-pin', isPinned: (el) => !!el && el.classList.contains('asga-pinned'), installed: '.asga-btn-installed', }; // CONFIRMED: #asga-bar is Modern App Store's own toolbar (search box, // sort dropdown, view toggle) and persists across BOTH the stock and // Modern grid views - it lives outside the card grid entirely, so // anchoring here avoids the grid-layout overlap bug from the previous // fix. Falls back to a grid-derived anchor only if Modern App Store // isn't installed at all (so #asga-bar never exists). function resolveAnchor(adapter) { const toolbar = document.querySelector('#asga-bar'); if (toolbar) return { node: toolbar, position: 'afterend', layout: 'toolbar' }; const firstCard = document.querySelector(adapter.card); if (firstCard && firstCard.parentElement) { return { node: firstCard.parentElement, position: 'beforebegin', layout: 'block' }; } return null; } function currentAdapter() { const modernToggle = document.querySelector('#asga-toggle-cb'); if (modernToggle && modernToggle.checked) return MODERN; return STOCK; } let hideInstalled = false; let observer = null; let inPinnedSection = false; let renderedFor = null; // which adapter .name the current controls belong to function isInstalledCard(card, adapter) { return card.querySelector(adapter.installed) !== null; } function isPinnedCard(card, adapter) { return adapter.isPinned(card.querySelector(adapter.pinToggle)); } function createUIControls(adapter) { if (document.querySelector('#ca-bulk-controls') && renderedFor === adapter.name) return; removeUIControls(); // in case we're switching adapters (view toggled) const anchorInfo = resolveAnchor(adapter); if (!anchorInfo) return; const controlsWrapper = document.createElement(anchorInfo.layout === 'toolbar' ? 'span' : 'div'); controlsWrapper.id = 'ca-bulk-controls'; if (anchorInfo.layout === 'toolbar') { // Sitting inline in the real toolbar alongside search/sort/toggle - // no need to force full width here, that toolbar already lays // its children out horizontally. controlsWrapper.style.cssText = [ 'display: inline-flex', 'align-items: center', 'gap: 12px', 'margin-left: 16px', 'white-space: nowrap', ].join(';'); } else { // Fallback context: sibling of the card grid itself, which needs // to escape the grid's own CSS Grid/Flexbox sizing rules or it // gets squeezed into a single cell and overlaps the first card. controlsWrapper.style.cssText = [ 'display: flex', 'align-items: center', 'gap: 16px', 'width: 100%', 'grid-column: 1 / -1', 'flex-basis: 100%', 'box-sizing: border-box', 'margin: 4px 0 14px 0', 'padding: 8px 12px', 'background: rgba(0, 0, 0, 0.35)', 'border-radius: 6px', 'position: relative', 'z-index: 2', ].join(';'); } const label = document.createElement('label'); label.style.cssText = 'display: inline-flex; align-items: center; cursor: pointer; user-select: none; font-weight: normal; white-space: nowrap;'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = 'toggle-hide-installed'; checkbox.checked = hideInstalled; checkbox.style.cssText = 'margin-right: 6px;'; checkbox.addEventListener('change', (e) => { hideInstalled = e.target.checked; applyHideFilter(currentAdapter()); }); label.appendChild(checkbox); label.appendChild(document.createTextNode('Hide Installed')); const bulkBtn = document.createElement('button'); bulkBtn.type = 'button'; bulkBtn.id = 'bulk-unpin-btn'; bulkBtn.className = 'ca_button'; bulkBtn.textContent = 'Unpin Installed Apps'; bulkBtn.style.cssText = 'background-color: #d9534f; color: #fff; border: none; padding: 6px 12px; cursor: pointer; white-space: nowrap; text-transform: none; border-radius: 4px; flex-shrink: 0;'; bulkBtn.addEventListener('click', () => handleBulkUnpin(currentAdapter())); controlsWrapper.appendChild(label); controlsWrapper.appendChild(bulkBtn); // Sibling, never a child - avoids getting wiped if the anchor's // own contents get replaced wholesale by CA or Modern App Store. anchorInfo.node.insertAdjacentElement(anchorInfo.position, controlsWrapper); renderedFor = adapter.name; } function removeUIControls() { const controls = document.querySelector('#ca-bulk-controls'); if (controls) controls.remove(); renderedFor = null; } function applyHideFilter(adapter) { if (!inPinnedSection) return; document.querySelectorAll(adapter.card).forEach((card) => { if (isInstalledCard(card, adapter)) { card.style.display = hideInstalled ? 'none' : ''; } }); } function handleBulkUnpin(adapter) { const targets = []; document.querySelectorAll(adapter.card).forEach((card) => { const pinToggle = card.querySelector(adapter.pinToggle); if (adapter.isPinned(pinToggle) && isInstalledCard(card, adapter)) { targets.push(pinToggle); } }); if (!targets.length) { alert('No installed pinned apps were found to unpin.'); return; } if (!confirm(`Unpin ${targets.length} installed app(s)? This cannot be undone in bulk.`)) return; targets.forEach((pinToggle) => pinToggle.click()); alert(`Unpinned ${targets.length} app(s).`); } function syncState() { const adapter = currentAdapter(); if (inPinnedSection) { createUIControls(adapter); applyHideFilter(adapter); } else { removeUIControls(); } } function initObserver() { const targetNode = document.querySelector('.mainArea') || document.body; observer = new MutationObserver(syncState); observer.observe(targetNode, { childList: true, subtree: true }); // CONFIRMED: CA's sidebar fires this click regardless of which grid // renderer (stock or Modern App Store) is currently drawing cards. document.body.addEventListener('click', (e) => { const sectionNav = e.target.closest('.sectionMenu'); if (sectionNav) { inPinnedSection = sectionNav.getAttribute('data-category') === 'pinned_apps'; syncState(); return; } if (e.target.closest('.categoryMenu, .caMenuItem')) { inPinnedSection = false; syncState(); } }); // Re-sync immediately when Modern App Store's own view toggle flips, // since that swaps the entire adapter (selectors + anchor) in place. document.body.addEventListener('change', (e) => { if (e.target && e.target.id === 'asga-toggle-cb') { syncState(); } }); syncState(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initObserver); } else { initObserver(); } })(); EOF # Inject JS script tag reference into CA's main view page if ! grep -q "ca.bulk.unpin/inject.js" /usr/local/emhttp/plugins/community.applications/Apps.page 2>/dev/null; then echo '' >> /usr/local/emhttp/plugins/community.applications/Apps.page fi echo "----------------------------------------------------" echo " ca.bulk.unpin has been successfully installed." echo "----------------------------------------------------" ]]> /dev/null rm -rf /usr/local/emhttp/plugins/ca.bulk.unpin echo "----------------------------------------------------" echo " ca.bulk.unpin has been uninstalled." echo "----------------------------------------------------" ]]>