diff --git a/.gitignore b/.gitignore index 3c3c6a0b..fdfaa90b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ skin_renders/ + +# JS test deps (test/js) +node_modules/ +package-lock.json diff --git a/test/js/README.md b/test/js/README.md new file mode 100644 index 00000000..dd91132c --- /dev/null +++ b/test/js/README.md @@ -0,0 +1,65 @@ +# Web-interface JS tests + +Covers `web_interface/static/v3/js/plugins/list_filter.js` (the shared +search/filter/sort controller) and the plugin-manager grids that use it: +Installed Plugins, the Plugin Store, and Starlark Apps. + +There is no JS toolchain in this repo, so these are plain node scripts with no +test framework. Each prints `ok`/`FAIL` lines and exits non-zero on failure. + +## Running + +```bash +cd test/js +npm install # jsdom, for the DOM suites only +node run_all.js +``` + +The unit suites need nothing but node. The DOM suites additionally need a +running web interface, because they test against the **real** server-rendered +HTML and the **real** API rather than fixtures: + +```bash +# in another shell, from the repo root +EMULATOR=true python3 web_interface/app.py # http://localhost:5000 + +# or point the suites at a device +BASE=http://10.0.10.169:5000 node run_all.js +``` + +`run_all.js` skips the DOM suites (rather than failing) when jsdom is missing or +nothing is listening, so it stays useful in a bare checkout. + +## The suites + +| Suite | Needs a server | Covers | +|---|---|---| +| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it | +| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata | +| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | +| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | +| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` | + +Point the DOM suites at a rig with a full plugin set when it matters — a dev box +with two plugins installed will pass while exercising very little. + +## Notes for whoever changes this next + +- The suites read the shipped files off disk and, for the DOM ones, the partial + from the running server. They do not keep their own copy of the markup, so + renaming an element id will fail them loudly rather than silently pass. +- `unit/test_list_filter.js` `eval`s a slice of `plugins_manager.js` located by + the text `function installedSortName(plugin)`. If that function is renamed, + fix the slice markers rather than pasting a copy of the config into the test. +- A few assertions exist specifically to stop earlier bugs coming back: + trailing spaces surviving the search debounce; a multi-word query that spans + two adjacent search fields (field order in the haystack is load-bearing); + `window.installedPlugins` staying at full length while the grid is filtered. +- Watch for assertions that can pass vacuously. Several here deliberately guard + against it — e.g. counting only non-skeleton cards, and asserting a search + phrase matches something before comparing two results. + +The old-vs-new differential suites used to verify that the store and Starlark +migrations were behaviour-preserving are not included: they compared against the +pre-refactor implementation, which now only exists in git history. See PR #540 +if that comparison ever needs redoing. diff --git a/test/js/dom/test_installed_dom.js b/test/js/dom/test_installed_dom.js new file mode 100644 index 00000000..5fbee8c6 --- /dev/null +++ b/test/js/dom/test_installed_dom.js @@ -0,0 +1,279 @@ +// Integration test in a REAL DOM (jsdom): +// - HTML comes from the running server's /partials/plugins (real template output) +// - list_filter.js is loaded as a real script +// - plugin data comes from the running server's real API +// - interactions are real dispatched DOM events on the real pill/select nodes +// This exercises HTML parsing, attribute reflection, event bubbling and +// delegation for real — none of which the hand-rolled shim could vouch for. +const fs = require('fs'); +const http = require('http'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const path = require('path'); +const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); +const BASE = process.env.BASE || 'http://localhost:5000'; + +function get(path) { + return new Promise((res, rej) => { + http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }) + .on('error', rej); + }); +} + +(async () => { + const partial = await get('/partials/plugins'); + const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins; + + // Collected so an uncaught error inside a handler fails the run instead of + // silently vanishing. + const jsErrors = []; + const vc = new VirtualConsole(); + vc.on('jsdomError', e => jsErrors.push(String(e.message || e))); + vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' '))); + + const dom = new JSDOM( + `
${partial}
`, + { runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' }); + + const { window } = dom; + const { document } = window; + + // Minimal ambient globals the extracted block expects from plugins_manager.js. + window.pluginLog = () => {}; + window.debugLog = () => {}; + window.PLUGIN_DEBUG = false; + window.installedPlugins = installed; + window.escapeHtml = function (text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Load the helper as a real ', + version: '1.0.0', category: 'x', description: 'nope', enabled: true, tags: ['t'], +}], 1); +const evil = container.innerHTML; +ok('no raw bad/.test(evil)); +ok('no raw from description', !/nope<\/b>/.test(evil)); +ok('no raw from tags', !/t<\/i>/.test(evil)); +ok('escaped entities present instead', /</.test(evil)); + +console.log(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail ? 1 : 0); diff --git a/web_interface/static/v3/js/plugins/list_filter.js b/web_interface/static/v3/js/plugins/list_filter.js new file mode 100644 index 00000000..6a8773ac --- /dev/null +++ b/web_interface/static/v3/js/plugins/list_filter.js @@ -0,0 +1,484 @@ +/** + * ListFilter — shared search / filter / sort controller for the card-grid + * sections of the Plugin Manager. + * + * The Installed Plugins, Plugin Store and Starlark Apps sections all need the + * same machinery: debounced text search over a few fields, a handful of filter + * axes, a sort dropdown, an active-filter count with a Clear button, and a + * re-render. This owns that machinery; the caller keeps ownership of its own + * card markup via the `render` callback. + * + * Usage: + * + * const ctl = ListFilter.create({ + * getItems: () => window.installedPlugins || [], + * render: (visible, total) => renderCards(visible, total), + * search: { el: 'my-search', fields: ['name', 'id', 'tags'] }, + * sort: { el: 'my-sort', default: 'a-z', comparators: { 'a-z': fn } }, + * controls: [{ type: 'pills', el: '#my-pills', attr: 'data-my-filter', + * key: 'filter', default: 'all', test: (item, v) => true }], + * clearEl: 'my-clear', + * }); + * ctl.bind(); // idempotent — safe to call after every HTMX partial swap + * ctl.apply(); // filter + sort + render + * + * Optional `pagination` slices the result set and renders page controls; the + * `render` callback then receives just the current page. Optional `persist` + * takes read/write callbacks so the caller — not this helper — owns its + * storage keys. + * + * Element references are DOM ids, except `controls[].el` which is a CSS + * selector for the pill container. + */ +const ListFilter = (function () { + 'use strict'; + + function debounce(fn, wait) { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), wait); + }; + } + + function byId(id) { + return id ? document.getElementById(id) : null; + } + + // Filter axes compare against their default to decide "is this axis active", + // so null/undefined/'' must not be conflated with a real selection. + function sameValue(a, b) { + if (a === b) return true; + if (a === null || a === undefined) return b === null || b === undefined; + return false; + } + + // Build the lowercased search haystack. Array fields (e.g. tags) are + // flattened in, matching the existing store/starlark search behaviour. + // + // Values are read out of a Map rather than via item[field], which keeps + // static analysers from flagging a computed member access as an + // object-injection sink. Iteration follows `fields`, NOT the object's own + // key order: the fields are concatenated, so their order decides which + // values end up adjacent, and a multi-word query can span a field boundary. + function haystack(item, fields) { + if (!item) return ''; + const values = new Map(Object.entries(item)); + const parts = []; + (fields || []).forEach(field => { + const value = values.get(field); + if (Array.isArray(value)) { + value.forEach(v => { if (v) parts.push(String(v)); }); + } else if (value) { + parts.push(String(value)); + } + }); + return parts.join(' ').toLowerCase(); + } + + function create(config) { + const cfg = config || {}; + const searchCfg = cfg.search || null; + const sortCfg = cfg.sort || null; + const controls = Array.isArray(cfg.controls) ? cfg.controls : []; + const pageCfg = cfg.pagination || null; + const persistCfg = cfg.persist || null; + const idOf = typeof cfg.idOf === 'function' ? cfg.idOf : (item => item && item.id); + + // Defaults double as the "inactive" value for each axis. + const defaults = {}; + if (searchCfg) { + defaults.search = ''; // trimmed — what filtering and activeCount use + defaults.searchRaw = ''; // exactly what the user typed — what the input shows + } + if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z'; + controls.forEach(c => { + defaults[c.key] = c.default !== undefined ? c.default : null; + }); + + const state = Object.assign({}, defaults); + + // page/perPage sit outside `defaults` on purpose: Clear Filters returns + // to page 1 but must NOT reset a per-page size the user chose. + if (pageCfg) { + state.page = 1; + state.perPage = pageCfg.defaultPerPage || 12; + } + + // Seed persisted values. The caller supplies read()/write() so storage + // keys stay where they always were. + if (persistCfg && typeof persistCfg.read === 'function') { + const saved = persistCfg.read() || {}; + if (sortCfg && saved.sort !== undefined && saved.sort !== null) state.sort = saved.sort; + if (pageCfg && saved.perPage) state.perPage = saved.perPage; + } + + function persist() { + if (persistCfg && typeof persistCfg.write === 'function') persistCfg.write(state); + } + + // Ids that stay visible even when they no longer match the active + // filters. Populated by the caller when the user acts on a card (e.g. + // toggling a plugin off while filtering by Enabled) so the card they + // just clicked doesn't vanish underneath the cursor. Cleared as soon as + // the user touches the toolbar. + const sticky = new Set(); + + function activeCount() { + let n = 0; + if (searchCfg && state.search) n++; + if (sortCfg && !sameValue(state.sort, defaults.sort)) n++; + controls.forEach(c => { + if (!sameValue(state[c.key], defaults[c.key])) n++; + }); + return n; + } + + function matches(item) { + if (searchCfg && state.search) { + if (!haystack(item, searchCfg.fields).includes(state.search.toLowerCase())) { + return false; + } + } + for (const c of controls) { + const value = state[c.key]; + if (sameValue(value, defaults[c.key])) continue; // axis inactive + if (typeof c.test === 'function' && !c.test(item, value)) return false; + } + return true; + } + + function compute() { + const all = (typeof cfg.getItems === 'function' ? cfg.getItems() : null) || []; + const total = all.length; + const list = all.filter(item => { + if (sticky.size > 0 && sticky.has(idOf(item))) return true; + return matches(item); + }); + + if (sortCfg && sortCfg.comparators) { + // An unrecognised sort key falls back to the default comparator, + // matching the switch-with-default the store code used. + const cmp = sortCfg.comparators[state.sort] || sortCfg.comparators[defaults.sort]; + if (typeof cmp === 'function') list.sort(cmp); + } + return { list: list, total: total }; + } + + // Reflect current state back onto the controls, so programmatic changes + // and a fresh partial swap both land on a correctly-lit toolbar. + function syncControls() { + if (searchCfg) { + // The toolbar markup is rebuilt on every HTMX partial swap while + // this controller (and its state) survives — put the text back. + const el = byId(searchCfg.el); + const text = state.searchRaw !== undefined ? state.searchRaw : state.search; + if (el && el.value !== text) el.value = text; + } + if (sortCfg) { + const el = byId(sortCfg.el); + if (el && el.value !== state.sort) el.value = state.sort; + } + if (pageCfg && pageCfg.perPageEl) { + const el = byId(pageCfg.perPageEl); + if (el && el.value !== String(state.perPage)) el.value = String(state.perPage); + } + controls.forEach(c => { + const value = state[c.key]; + if (c.type === 'pills') { + const container = document.querySelector(c.el); + if (!container) return; + container.querySelectorAll('[' + c.attr + ']').forEach(btn => { + const on = btn.getAttribute(c.attr) === String(value); + btn.setAttribute('data-active', on ? 'true' : 'false'); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } else if (c.type === 'select') { + const el = byId(c.el); + if (el && el.value !== (value === null ? '' : value)) { + el.value = value === null ? '' : value; + } + } else if (c.type === 'cycle') { + const btn = byId(c.el); + // The caller renders cycle buttons so each section keeps its + // own label/icon/class treatment. + if (btn && typeof c.render === 'function') c.render(btn, value); + } + }); + } + + function updateChrome(list, total) { + const n = activeCount(); + + const countEl = byId(cfg.countEl); + if (countEl && typeof cfg.countFormat === 'function') { + countEl.textContent = cfg.countFormat(list.length, total, n > 0); + } + + const activeEl = byId(cfg.activeCountEl); + if (activeEl) { + activeEl.classList.toggle('hidden', n === 0); + activeEl.textContent = n + ' filter' + (n !== 1 ? 's' : '') + ' active'; + } + + const clearEl = byId(cfg.clearEl); + if (clearEl) clearEl.classList.toggle('hidden', n === 0); + + if (searchCfg && searchCfg.clearEl) { + const searchClear = byId(searchCfg.clearEl); + if (searchClear) searchClear.classList.toggle('hidden', !state.search); + } + + syncControls(); + + if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total); + } + + // Page-number strip with leading/trailing ellipsis, producing the same + // controls the plugin store has always rendered. + // + // Built with createElement rather than by concatenating an HTML string. + // Nothing interpolated here is user-controlled — only page integers and + // these class constants — but assembling markup into innerHTML is the + // pattern static analysers flag as an XSS sink, and building nodes is no + // less clear. It also lets each button own its listener directly instead + // of re-querying the container afterwards. + const PAGE_BTN_CLASS = 'px-3 py-1 text-sm rounded-md border transition-colors'; + const PAGE_ACTIVE_CLASS = 'bg-blue-600 text-white border-blue-600'; + const PAGE_NORMAL_CLASS = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; + const PAGE_DISABLED_CLASS = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; + + function renderPagination(containerId, totalPages, currentPage) { + const container = byId(containerId); + if (!container) return; + + // textContent = '' drops the previous strip without parsing markup. + container.textContent = ''; + if (totalPages <= 1) return; + + const goTo = target => { + if (target >= 1 && target <= totalPages && target !== currentPage) { + state.page = target; + // Page moves re-slice only; filters and sort are unchanged. + apply(true); + const grid = byId(pageCfg && pageCfg.scrollToEl); + if (grid && typeof grid.scrollIntoView === 'function') { + grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }; + + const addPageButton = (label, target, variant) => { + const btn = document.createElement('button'); + btn.className = PAGE_BTN_CLASS + ' ' + ( + variant === 'active' ? PAGE_ACTIVE_CLASS + : variant === 'disabled' ? PAGE_DISABLED_CLASS + : PAGE_NORMAL_CLASS); + btn.setAttribute('data-list-page', String(target)); + if (variant === 'disabled') btn.disabled = true; + btn.textContent = label; + btn.addEventListener('click', () => goTo(target)); + container.appendChild(btn); + }; + + addPageButton('\u00ab', currentPage - 1, currentPage <= 1 ? 'disabled' : 'normal'); + + const pages = []; + pages.push(1); + if (currentPage > 3) pages.push('...'); + for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { + pages.push(i); + } + if (currentPage < totalPages - 2) pages.push('...'); + if (totalPages > 1) pages.push(totalPages); + + pages.forEach(entry => { + if (entry === '...') { + const gap = document.createElement('span'); + gap.className = 'px-2 py-1 text-sm text-gray-400'; + gap.textContent = '\u2026'; + container.appendChild(gap); + } else { + addPageButton(String(entry), entry, entry === currentPage ? 'active' : 'normal'); + } + }); + + addPageButton('\u00bb', currentPage + 1, currentPage >= totalPages ? 'disabled' : 'normal'); + } + + function apply(skipPageReset) { + const result = compute(); + + if (!pageCfg) { + updateChrome(result.list, result.total); + if (typeof cfg.render === 'function') cfg.render(result.list, result.total); + return result; + } + + if (!skipPageReset) state.page = 1; + + const total = result.list.length; + const totalPages = Math.max(1, Math.ceil(total / state.perPage)); + if (state.page > totalPages) state.page = totalPages; + + const start = (state.page - 1) * state.perPage; + const end = Math.min(start + state.perPage, total); + const pageItems = result.list.slice(start, end); + + const info = total > 0 + ? (typeof pageCfg.infoFormat === 'function' + ? pageCfg.infoFormat(start + 1, end, total) + : `Showing ${start + 1}\u2013${end} of ${total}`) + : (pageCfg.emptyText || 'No results match your filters'); + [pageCfg.infoEl, pageCfg.infoBottomEl].forEach(id => { + const el = byId(id); + if (el) el.textContent = info; + }); + + renderPagination(pageCfg.topEl, totalPages, state.page); + renderPagination(pageCfg.bottomEl, totalPages, state.page); + + updateChrome(result.list, result.total); + if (typeof cfg.render === 'function') cfg.render(pageItems, result.total); + return result; + } + + function setSearch(value) { + // Keep the raw text so syncControls can put it back verbatim. Writing + // the trimmed value into the input would eat a trailing space (and + // reset the caret) mid-word, which makes multi-word terms untypable. + state.searchRaw = value || ''; + state.search = state.searchRaw.trim(); + sticky.clear(); + apply(); + } + + function reset() { + // Only the filter axes reset; a chosen page size is a preference, + // not a filter, so it survives Clear Filters. + Object.assign(state, defaults); + if (pageCfg) state.page = 1; + sticky.clear(); + if (searchCfg) { + const el = byId(searchCfg.el); + if (el) el.value = ''; + } + persist(); + syncControls(); + apply(); + } + + function bind() { + if (searchCfg) { + const input = byId(searchCfg.el); + if (input && !input._listFilterInit) { + input._listFilterInit = true; + const run = debounce(() => setSearch(input.value), searchCfg.debounceMs || 300); + input.addEventListener('input', run); + input.addEventListener('keydown', e => { + if (e.key === 'Escape') { + input.value = ''; + setSearch(''); + } + }); + } + const searchClear = searchCfg.clearEl ? byId(searchCfg.clearEl) : null; + if (searchClear && !searchClear._listFilterInit) { + searchClear._listFilterInit = true; + searchClear.addEventListener('click', () => { + const el = byId(searchCfg.el); + if (el) el.value = ''; + setSearch(''); + }); + } + } + + if (sortCfg) { + const el = byId(sortCfg.el); + if (el && !el._listFilterInit) { + el._listFilterInit = true; + el.addEventListener('change', function () { + state.sort = this.value; + sticky.clear(); + persist(); + apply(); + }); + } + } + + controls.forEach(c => { + if (c.type === 'pills') { + const container = document.querySelector(c.el); + if (!container || container._listFilterInit) return; + container._listFilterInit = true; + // Delegated, so the pills survive any markup re-render. + container.addEventListener('click', event => { + const btn = event.target.closest('[' + c.attr + ']'); + if (!btn || !container.contains(btn)) return; + state[c.key] = btn.getAttribute(c.attr); + sticky.clear(); + apply(); + }); + } else if (c.type === 'select') { + const el = byId(c.el); + if (!el || el._listFilterInit) return; + el._listFilterInit = true; + el.addEventListener('change', function () { + state[c.key] = this.value; + sticky.clear(); + apply(); + }); + } else if (c.type === 'cycle') { + const btn = byId(c.el); + if (!btn || btn._listFilterInit) return; + btn._listFilterInit = true; + const values = Array.isArray(c.values) ? c.values : [null]; + btn.addEventListener('click', () => { + const at = values.findIndex(v => sameValue(v, state[c.key])); + state[c.key] = values[(at + 1) % values.length]; + sticky.clear(); + apply(); + }); + } + }); + + if (pageCfg && pageCfg.perPageEl) { + const el = byId(pageCfg.perPageEl); + if (el && !el._listFilterInit) { + el._listFilterInit = true; + el.addEventListener('change', function () { + state.perPage = parseInt(this.value) || (pageCfg.defaultPerPage || 12); + persist(); + apply(); + }); + } + } + + const clearEl = byId(cfg.clearEl); + if (clearEl && !clearEl._listFilterInit) { + clearEl._listFilterInit = true; + clearEl.addEventListener('click', reset); + } + } + + return { + state: state, + sticky: sticky, + activeCount: activeCount, + bind: bind, + apply: apply, + reset: reset, + setSearch: setSearch, + syncControls: syncControls, + }; + } + + return { create: create }; +})(); + +// Export +if (typeof module !== 'undefined' && module.exports) { + module.exports = ListFilter; +} else { + window.ListFilter = ListFilter; +} diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 80a924fa..a16e66f6 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -884,40 +884,11 @@ window.currentPluginConfig = null; let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads let cacheTimestamp = null; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds - let storeFilteredList = []; function storeCacheExpired() { return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION); } - // ── Plugin Store Filter State ─────────────────────────────────────────── - const storeFilterState = { - sort: safeLocalStorage.getItem('storeSort') || 'a-z', - filterCategory: '', - filterInstalled: null, // null=all, true=installed, false=not-installed - searchQuery: '', - page: 1, - perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || 12, - persist() { - safeLocalStorage.setItem('storeSort', this.sort); - safeLocalStorage.setItem('storePerPage', this.perPage); - }, - reset() { - this.sort = 'a-z'; - this.filterCategory = ''; - this.filterInstalled = null; - this.searchQuery = ''; - this.page = 1; - }, - activeCount() { - let n = 0; - if (this.searchQuery) n++; - if (this.filterInstalled !== null) n++; - if (this.filterCategory) n++; - if (this.sort !== 'a-z') n++; - return n; - } - }; let onDemandStatusInterval = null; let currentOnDemandPluginId = null; let hasLoadedOnDemandStatus = false; @@ -1069,11 +1040,8 @@ window.initPluginsPage = function() { restartBtn.replaceWith(restartBtn.cloneNode(true)); document.getElementById('restart-display-btn').addEventListener('click', restartDisplay); } - // Restore persisted store sort/perPage - const storeSortEl = document.getElementById('store-sort'); - if (storeSortEl) storeSortEl.value = storeFilterState.sort; - const storePpEl = document.getElementById('store-per-page'); - if (storePpEl) storePpEl.value = storeFilterState.perPage; + // Persisted store sort/perPage are restored by the controller's syncControls(). + setupInstalledFilterListeners(); setupStoreFilterListeners(); if (closeOnDemandModalBtn) { @@ -1212,18 +1180,12 @@ function initializePlugins() { } }); - // Setup search functionality (with guard against duplicate listeners) - const searchInput = document.getElementById('plugin-search'); - const categorySelect = document.getElementById('plugin-category'); - - if (searchInput && !searchInput._listenerSetup) { - searchInput._listenerSetup = true; - searchInput.addEventListener('input', debounce(searchPluginStore, 300)); - } - if (categorySelect && !categorySelect._listenerSetup) { - categorySelect._listenerSetup = true; - categorySelect.addEventListener('change', searchPluginStore); - } + // #plugin-search and #plugin-category are wired by the store's ListFilter + // controller (setupStoreFilterListeners). They used to ALSO be bound here to + // searchPluginStore; because that binding passed the DOM event as the + // `fetchCommitInfo` argument, every keystroke and category change skipped the + // cached-filter fast path and refetched /api/v3/plugins/store/list with commit + // info. Filtering the cached list is the controller's job — leave it to it. // Setup GitHub installation handlers debugLog('[initializePlugins] About to call setupGitHubInstallHandlers...'); @@ -1328,13 +1290,9 @@ function loadInstalledPlugins(forceRefresh = false) { }); } + // Also refreshes the '#installed-count' text via the filter controller. renderInstalledPlugins(installedPlugins); - // Update count - const countEl = document.getElementById('installed-count'); - if (countEl) { - countEl.textContent = installedPlugins.length + ' installed'; - } return installedPlugins; } else { const errorMsg = 'Failed to load installed plugins: ' + data.message; @@ -1369,6 +1327,142 @@ function refreshInstalledPlugins() { window.pluginManager.loadInstalledPlugins = loadInstalledPlugins; // Note: searchPluginStore will be exposed after its definition (see below) +// ── Installed Plugins: search / filter / sort ─────────────────────────── +// Sort comparators for the installed-plugins toolbar. Kept beside the render +// code they drive rather than inside the ListFilter helper, which stays +// section-agnostic. +function installedSortName(plugin) { + return String((plugin && (plugin.name || plugin.id)) || '').toLowerCase(); +} + +// last_updated is the plugin's local git commit date (date_iso from +// _get_local_git_info, see api_v3.py). It can be absent or unparseable. +function installedUpdatedAt(plugin) { + const raw = plugin ? plugin.last_updated : null; + if (!raw) return null; + const t = Date.parse(raw); + return Number.isNaN(t) ? null : t; +} + +const INSTALLED_COMPARATORS = { + 'a-z': (a, b) => installedSortName(a).localeCompare(installedSortName(b)), + 'z-a': (a, b) => installedSortName(b).localeCompare(installedSortName(a)), + 'status': (a, b) => { + const rank = p => (p.update_available ? 0 : (p.enabled ? 1 : 2)); + const diff = rank(a) - rank(b); + return diff !== 0 ? diff : installedSortName(a).localeCompare(installedSortName(b)); + }, + 'recent': (a, b) => { + const ta = installedUpdatedAt(a); + const tb = installedUpdatedAt(b); + // Plugins with no usable timestamp sort to the end, never to the top. + if (ta === null && tb === null) return installedSortName(a).localeCompare(installedSortName(b)); + if (ta === null) return 1; + if (tb === null) return -1; + return tb - ta; + }, + 'category': (a, b) => { + const catCmp = String(a.category || '').localeCompare(String(b.category || '')); + return catCmp !== 0 ? catCmp : installedSortName(a).localeCompare(installedSortName(b)); + }, +}; + +let _installedFilter = null; + +// Created lazily so a script load-order problem degrades to "no filtering" +// instead of throwing while this file is still being evaluated. +function getInstalledFilter() { + if (_installedFilter) return _installedFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[PLUGINS] ListFilter helper unavailable — installed plugin filters disabled'); + return null; + } + _installedFilter = window.ListFilter.create({ + // Always read the canonical list, never a captured snapshot. + getItems: () => window.installedPlugins || installedPlugins || [], + render: renderInstalledCards, + search: { + el: 'installed-search', + clearEl: 'installed-search-clear', + fields: ['name', 'id', 'description', 'author', 'category', 'tags'], + debounceMs: 200, + }, + sort: { + el: 'installed-sort', + default: 'a-z', + comparators: INSTALLED_COMPARATORS, + }, + controls: [{ + type: 'pills', + el: '#installed-filter-pills', + attr: 'data-installed-filter', + key: 'status', + default: 'all', + test: (plugin, value) => { + if (value === 'enabled') return Boolean(plugin.enabled); + if (value === 'disabled') return !plugin.enabled; + // update_available is computed server-side (api_v3.py) — never + // recompute it from version strings here. + if (value === 'updates') return Boolean(plugin.update_available); + return true; + }, + }], + clearEl: 'installed-clear-filters', + countEl: 'installed-count', + countFormat: (shown, total, filtered) => + filtered ? `${shown} of ${total} shown` : `${total} installed`, + onChrome: updateInstalledUpdatesBadge, + }); + return _installedFilter; +} + +// The Updates pill counts against the *full* list, not the filtered view, so +// the badge keeps telling you how much work is outstanding while you browse. +function updateInstalledUpdatesBadge() { + const all = window.installedPlugins || installedPlugins || []; + const n = all.filter(p => p && p.update_available).length; + + const badge = document.getElementById('installed-updates-count'); + if (badge) { + badge.textContent = String(n); + badge.classList.toggle('hidden', n === 0); + } + + const pill = document.querySelector('#installed-filter-pills [data-installed-filter="updates"]'); + if (pill) { + pill.classList.toggle('opacity-50', n === 0); + pill.title = n === 0 + ? 'No plugins have updates available' + : `Show only the ${n} plugin${n !== 1 ? 's' : ''} with a newer version available`; + } +} + +function applyInstalledFiltersAndRender() { + const ctl = getInstalledFilter(); + if (ctl) { + ctl.apply(); + return; + } + // Fallback: render everything, so the section still works without the helper. + const all = window.installedPlugins || installedPlugins || []; + renderInstalledCards(all, all.length); + const countEl = document.getElementById('installed-count'); + if (countEl) countEl.textContent = all.length + ' installed'; +} + +function setupInstalledFilterListeners() { + const ctl = getInstalledFilter(); + if (!ctl) return; + // Both are idempotent — the plugins partial is HTMX-swapped, so this runs + // again on every return to the tab. + ctl.bind(); + ctl.syncControls(); +} + +// Publishes the canonical installed-plugin list, then renders through the +// active filters. Everything that reads window.installedPlugins (the toggle +// handler, isStorePluginInstalled, runUpdateAllPlugins, the Alpine config tabs) +// depends on this receiving the FULL list — never a filtered subset. function renderInstalledPlugins(plugins) { const container = document.getElementById('installed-plugins-grid'); if (!container) { @@ -1399,11 +1493,25 @@ function renderInstalledPlugins(plugins) { } } + applyInstalledFiltersAndRender(); +} + +// Renders the card grid only. `plugins` is the visible (filtered) subset and +// `total` the full installed count — this must NOT touch window.installedPlugins. +function renderInstalledCards(plugins, total) { + const container = document.getElementById('installed-plugins-grid'); + if (!container) return; + + const totalCount = (typeof total === 'number') ? total : plugins.length; + // Remove skeleton cards before rendering real content container.querySelectorAll('.installed-skeleton').forEach(el => el.remove()); + // Attached before the early returns so the empty state's Clear button works. + setupInstalledEventDelegation(); + if (plugins.length === 0) { - container.innerHTML = ` + container.innerHTML = totalCount === 0 ? `
@@ -1411,6 +1519,18 @@ function renderInstalledPlugins(plugins) {

No plugins installed

Install plugins from the store to get started

+ ` : ` +
+
+ +
+

No plugins match your filters

+

${totalCount} plugin${totalCount !== 1 ? 's' : ''} installed, none matching the current search or filters.

+ +
`; return; } @@ -1519,39 +1639,34 @@ function renderInstalledPlugins(plugins) {
`; }).join(''); +} - // Set up event delegation for plugin action buttons (fallback if onclick doesn't work) - // Only set up once per container to avoid redundant listeners - const setupEventDelegation = () => { - const container = document.getElementById('installed-plugins-grid'); - if (!container) { - pluginLog('[RENDER] installed-plugins-grid not found for event delegation'); - return; - } - - // Skip if already set up (guard against multiple calls) - if (container._eventDelegationSetup) { - pluginLog('[RENDER] Event delegation already set up, skipping'); - return; - } - - // Mark as set up - container._eventDelegationSetup = true; - container._pluginActionHandler = handlePluginAction; +// Set up event delegation for plugin action buttons (fallback if onclick doesn't work) +// Only set up once per container to avoid redundant listeners, which is what +// makes it safe to rebuild the grid's innerHTML on every keystroke. +function setupInstalledEventDelegation() { + const container = document.getElementById('installed-plugins-grid'); + if (!container) { + pluginLog('[RENDER] installed-plugins-grid not found for event delegation'); + return; + } - // Add listeners for both click and change events - container.addEventListener('click', handlePluginAction, true); - container.addEventListener('change', handlePluginAction, true); - pluginLog('[RENDER] Event delegation set up for installed-plugins-grid'); - }; + // Skip if already set up (guard against multiple calls) + if (container._eventDelegationSetup) { + return; + } - // Set up immediately - setupEventDelegation(); + // Mark as set up + container._eventDelegationSetup = true; + container._pluginActionHandler = handlePluginAction; - // Also retry after a short delay to ensure it's attached even if container wasn't ready - setTimeout(setupEventDelegation, 100); + // Add listeners for both click and change events + container.addEventListener('click', handlePluginAction, true); + container.addEventListener('change', handlePluginAction, true); + pluginLog('[RENDER] Event delegation set up for installed-plugins-grid'); } + function handlePluginAction(event) { // Check for both button and input (for toggle) const button = event.target.closest('button[data-action]') || event.target.closest('input[data-action]'); @@ -1560,6 +1675,15 @@ function handlePluginAction(event) { const action = button.getAttribute('data-action'); const pluginId = button.getAttribute('data-plugin-id'); + // Grid-level actions have no plugin id (the empty state's Clear button). + if (action === 'clear-installed-filters') { + event.preventDefault(); + event.stopPropagation(); + const ctl = getInstalledFilter(); + if (ctl) ctl.reset(); + return; + } + if (!pluginId) return; event.preventDefault(); @@ -1587,6 +1711,13 @@ function handlePluginAction(event) { switch(action) { case 'toggle': + // Toggling under an Enabled/Disabled filter would otherwise make the + // card vanish the moment the server confirms. Pin it until the user + // next touches the toolbar. + { + const ctl = getInstalledFilter(); + if (ctl) ctl.sticky.add(pluginId); + } // Get the current enabled state from plugin data (source of truth) // rather than from the checkbox DOM which might be out of sync const plugin = (window.installedPlugins || []).find(p => p.id === pluginId); @@ -3494,249 +3625,130 @@ function isStorePluginInstalled(pluginIdOrPlugin) { return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)); } -function applyStoreFiltersAndSort(skipPageReset) { - if (!pluginStoreCache) return; - const st = storeFilterState; - - let list = pluginStoreCache.slice(); - - // Text search - if (st.searchQuery) { - const q = st.searchQuery.toLowerCase(); - list = list.filter(plugin => { - const hay = [ - plugin.name, plugin.description, plugin.author, - plugin.id, plugin.category, - ...(plugin.tags || []) - ].filter(Boolean).join(' ').toLowerCase(); - return hay.includes(q); - }); - } - - // Category filter - if (st.filterCategory) { - const cat = st.filterCategory.toLowerCase(); - list = list.filter(plugin => (plugin.category || '').toLowerCase() === cat); - } - - // Installed filter - if (st.filterInstalled === true) { - list = list.filter(plugin => isStorePluginInstalled(plugin)); - } else if (st.filterInstalled === false) { - list = list.filter(plugin => !isStorePluginInstalled(plugin)); - } - - // Sort - list.sort((a, b) => { - const nameA = (a.name || a.id || '').toLowerCase(); - const nameB = (b.name || b.id || '').toLowerCase(); - switch (st.sort) { - case 'z-a': return nameB.localeCompare(nameA); - case 'category': { - const catCmp = (a.category || '').localeCompare(b.category || ''); - return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); - } - case 'author': { - const authCmp = (a.author || '').localeCompare(b.author || ''); - return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); - } - case 'newest': { - const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0; - const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0; - return dateB - dateA; // newest first - } - default: return nameA.localeCompare(nameB); - } - }); - - storeFilteredList = list; - if (!skipPageReset) st.page = 1; - - renderStorePage(); - updateStoreFilterUI(); -} +// ── Plugin Store: search / filter / sort ──────────────────────────────── +// Behaviour, element ids and localStorage keys are unchanged from the +// hand-rolled version this replaces — only the machinery is now shared. +const STORE_COMPARATORS = { + 'a-z': (a, b) => storeSortName(a).localeCompare(storeSortName(b)), + 'z-a': (a, b) => storeSortName(b).localeCompare(storeSortName(a)), + 'category': (a, b) => { + const catCmp = (a.category || '').localeCompare(b.category || ''); + return catCmp !== 0 ? catCmp : storeSortName(a).localeCompare(storeSortName(b)); + }, + 'author': (a, b) => { + const authCmp = (a.author || '').localeCompare(b.author || ''); + return authCmp !== 0 ? authCmp : storeSortName(a).localeCompare(storeSortName(b)); + }, + 'newest': (a, b) => { + // Missing dates count as epoch 0, so they land last under a descending + // sort — same as before. + const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0; + const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0; + return dateB - dateA; // newest first + }, +}; -function renderStorePage() { - const st = storeFilterState; - const total = storeFilteredList.length; - const totalPages = Math.max(1, Math.ceil(total / st.perPage)); - if (st.page > totalPages) st.page = totalPages; - - const start = (st.page - 1) * st.perPage; - const end = Math.min(start + st.perPage, total); - const pagePlugins = storeFilteredList.slice(start, end); - - // Results info - const info = total > 0 - ? `Showing ${start + 1}\u2013${end} of ${total} plugins` - : 'No plugins match your filters'; - const infoEl = document.getElementById('store-results-info'); - const infoElBot = document.getElementById('store-results-info-bottom'); - if (infoEl) infoEl.textContent = info; - if (infoElBot) infoElBot.textContent = info; - - // Pagination - renderStorePagination('store-pagination-top', totalPages, st.page); - renderStorePagination('store-pagination-bottom', totalPages, st.page); - - // Grid - renderPluginStore(pagePlugins); +function storeSortName(plugin) { + return (plugin.name || plugin.id || '').toLowerCase(); } -function renderStorePagination(containerId, totalPages, currentPage) { - const container = document.getElementById(containerId); - if (!container) return; - - if (totalPages <= 1) { container.innerHTML = ''; return; } +let _storeFilter = null; - const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; - const activeClass = 'bg-blue-600 text-white border-blue-600'; - const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; - const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; - - let html = ''; - html += ``; - - const pages = []; - pages.push(1); - if (currentPage > 3) pages.push('...'); - for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { - pages.push(i); +function getStoreFilter() { + if (_storeFilter) return _storeFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[PLUGINS] ListFilter helper unavailable — store filters disabled'); + return null; } - if (currentPage < totalPages - 2) pages.push('...'); - if (totalPages > 1) pages.push(totalPages); - - pages.forEach(p => { - if (p === '...') { - html += ``; - } else { - html += ``; - } - }); - - html += ``; - - container.innerHTML = html; - - container.querySelectorAll('[data-store-page]').forEach(btn => { - btn.addEventListener('click', function() { - const p = parseInt(this.getAttribute('data-store-page')); - if (p >= 1 && p <= totalPages && p !== currentPage) { - storeFilterState.page = p; - renderStorePage(); - const grid = document.getElementById('plugin-store-grid'); - if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }); + _storeFilter = window.ListFilter.create({ + getItems: () => pluginStoreCache || [], + render: renderPluginStore, + search: { + el: 'plugin-search', + fields: ['name', 'description', 'author', 'id', 'category', 'tags'], + debounceMs: 300, + }, + sort: { + el: 'store-sort', + default: 'a-z', + comparators: STORE_COMPARATORS, + }, + controls: [ + { + type: 'select', + el: 'plugin-category', + key: 'filterCategory', + default: '', + test: (plugin, value) => (plugin.category || '').toLowerCase() === value.toLowerCase(), + }, + { + // Cycles All → Installed → Not Installed → All. + type: 'cycle', + el: 'store-filter-installed', + key: 'filterInstalled', + default: null, + values: [null, true, false], + test: (plugin, value) => (value === true ? isStorePluginInstalled(plugin) : !isStorePluginInstalled(plugin)), + render: (btn, value) => { + if (value === true) { + btn.innerHTML = 'Installed'; + btn.classList.add('border-green-400', 'bg-green-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); + } else if (value === false) { + btn.innerHTML = 'Not Installed'; + btn.classList.add('border-red-400', 'bg-red-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); + } else { + btn.innerHTML = 'All'; + btn.classList.add('border-gray-300', 'bg-white'); + btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); + } + }, + }, + ], + pagination: { + perPageEl: 'store-per-page', + defaultPerPage: 12, + topEl: 'store-pagination-top', + bottomEl: 'store-pagination-bottom', + infoEl: 'store-results-info', + infoBottomEl: 'store-results-info-bottom', + scrollToEl: 'plugin-store-grid', + infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} plugins`, + emptyText: 'No plugins match your filters', + }, + persist: { + read: () => ({ + sort: safeLocalStorage.getItem('storeSort') || undefined, + perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || undefined, + }), + write: (state) => { + safeLocalStorage.setItem('storeSort', state.sort); + safeLocalStorage.setItem('storePerPage', state.perPage); + }, + }, + clearEl: 'store-clear-filters', + activeCountEl: 'store-active-filters', }); + return _storeFilter; } -function updateStoreFilterUI() { - const st = storeFilterState; - const count = st.activeCount(); - - const badge = document.getElementById('store-active-filters'); - const clearBtn = document.getElementById('store-clear-filters'); - if (badge) { - badge.classList.toggle('hidden', count === 0); - badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; - } - if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); - - const instBtn = document.getElementById('store-filter-installed'); - if (instBtn) { - if (st.filterInstalled === true) { - instBtn.innerHTML = 'Installed'; - instBtn.classList.add('border-green-400', 'bg-green-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); - } else if (st.filterInstalled === false) { - instBtn.innerHTML = 'Not Installed'; - instBtn.classList.add('border-red-400', 'bg-red-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); - } else { - instBtn.innerHTML = 'All'; - instBtn.classList.add('border-gray-300', 'bg-white'); - instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); - } +function applyStoreFiltersAndSort(skipPageReset) { + if (!pluginStoreCache) return; + const ctl = getStoreFilter(); + if (ctl) { + ctl.apply(skipPageReset); + return; } + // Fallback: no helper, render the cache unfiltered rather than nothing. + renderPluginStore(pluginStoreCache); } function setupStoreFilterListeners() { - // Search with debounce - const searchEl = document.getElementById('plugin-search'); - if (searchEl && !searchEl._storeFilterInit) { - searchEl._storeFilterInit = true; - let debounce = null; - searchEl.addEventListener('input', function() { - clearTimeout(debounce); - debounce = setTimeout(() => { - storeFilterState.searchQuery = this.value.trim(); - applyStoreFiltersAndSort(); - }, 300); - }); - } - - // Category dropdown - const catEl = document.getElementById('plugin-category'); - if (catEl && !catEl._storeFilterInit) { - catEl._storeFilterInit = true; - catEl.addEventListener('change', function() { - storeFilterState.filterCategory = this.value; - applyStoreFiltersAndSort(); - }); - } - - // Sort dropdown - const sortEl = document.getElementById('store-sort'); - if (sortEl && !sortEl._storeFilterInit) { - sortEl._storeFilterInit = true; - sortEl.addEventListener('change', function() { - storeFilterState.sort = this.value; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } - - // Installed toggle (cycle: all → installed → not-installed → all) - const instBtn = document.getElementById('store-filter-installed'); - if (instBtn && !instBtn._storeFilterInit) { - instBtn._storeFilterInit = true; - instBtn.addEventListener('click', function() { - const st = storeFilterState; - if (st.filterInstalled === null) st.filterInstalled = true; - else if (st.filterInstalled === true) st.filterInstalled = false; - else st.filterInstalled = null; - applyStoreFiltersAndSort(); - }); - } - - // Clear filters - const clearBtn = document.getElementById('store-clear-filters'); - if (clearBtn && !clearBtn._storeFilterInit) { - clearBtn._storeFilterInit = true; - clearBtn.addEventListener('click', function() { - storeFilterState.reset(); - const searchEl = document.getElementById('plugin-search'); - if (searchEl) searchEl.value = ''; - const catEl = document.getElementById('plugin-category'); - if (catEl) catEl.value = ''; - const sortEl = document.getElementById('store-sort'); - if (sortEl) sortEl.value = 'a-z'; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } - - // Per-page selector - const ppEl = document.getElementById('store-per-page'); - if (ppEl && !ppEl._storeFilterInit) { - ppEl._storeFilterInit = true; - ppEl.addEventListener('change', function() { - storeFilterState.perPage = parseInt(this.value) || 12; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } + const ctl = getStoreFilter(); + if (!ctl) return; + ctl.bind(); + ctl.syncControls(); } // Expose searchPluginStore on window.pluginManager for Alpine.js integration @@ -5608,41 +5620,8 @@ document.addEventListener('htmx:afterSettle', function() { let starlarkSectionVisible = false; let starlarkFullCache = null; // All apps from server - let starlarkFilteredList = []; // After filters applied let starlarkDataLoaded = false; - // ── Filter State ──────────────────────────────────────────────────────── - const starlarkFilterState = { - sort: safeLocalStorage.getItem('starlarkSort') || 'a-z', - filterInstalled: null, // null=all, true=installed, false=not-installed - filterAuthor: '', - filterCategory: '', - searchQuery: '', - page: 1, - perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || 24, - persist() { - safeLocalStorage.setItem('starlarkSort', this.sort); - safeLocalStorage.setItem('starlarkPerPage', this.perPage); - }, - reset() { - this.sort = 'a-z'; - this.filterInstalled = null; - this.filterAuthor = ''; - this.filterCategory = ''; - this.searchQuery = ''; - this.page = 1; - }, - activeCount() { - let n = 0; - if (this.searchQuery) n++; - if (this.filterInstalled !== null) n++; - if (this.filterAuthor) n++; - if (this.filterCategory) n++; - if (this.sort !== 'a-z') n++; - return n; - } - }; - // ── Helpers ───────────────────────────────────────────────────────────── function escapeHtml(str) { if (!str) return ''; @@ -5681,12 +5660,7 @@ document.addEventListener('htmx:afterSettle', function() { }); } - // Restore persisted sort/perPage - const sortEl = document.getElementById('starlark-sort'); - if (sortEl) sortEl.value = starlarkFilterState.sort; - const ppEl = document.getElementById('starlark-per-page'); - if (ppEl) ppEl.value = starlarkFilterState.perPage; - + // Persisted sort/perPage are restored by the controller's syncControls(). setupStarlarkFilterListeners(); const uploadBtn = document.getElementById('starlark-upload-btn'); @@ -5787,147 +5761,126 @@ document.addEventListener('htmx:afterSettle', function() { } // ── Apply Filters + Sort ──────────────────────────────────────────────── - function applyStarlarkFiltersAndSort(skipPageReset) { - if (!starlarkFullCache) return; - const st = starlarkFilterState; - - let list = starlarkFullCache.slice(); - - // Text search - if (st.searchQuery) { - const q = st.searchQuery.toLowerCase(); - list = list.filter(app => { - const hay = [app.name, app.summary, app.desc, app.author, app.id, app.category] - .filter(Boolean).join(' ').toLowerCase(); - return hay.includes(q); - }); - } + // ── Filter / Sort / Paginate ──────────────────────────────────────────── + // Same behaviour, element ids and localStorage keys as the hand-rolled + // version this replaces; the machinery is now shared with the store and + // the installed-plugins list. + function starlarkSortName(app) { + return (app.name || app.id || '').toLowerCase(); + } + + const STARLARK_COMPARATORS = { + 'a-z': (a, b) => starlarkSortName(a).localeCompare(starlarkSortName(b)), + 'z-a': (a, b) => starlarkSortName(b).localeCompare(starlarkSortName(a)), + 'category': (a, b) => { + const catCmp = (a.category || '').localeCompare(b.category || ''); + return catCmp !== 0 ? catCmp : starlarkSortName(a).localeCompare(starlarkSortName(b)); + }, + 'author': (a, b) => { + const authCmp = (a.author || '').localeCompare(b.author || ''); + return authCmp !== 0 ? authCmp : starlarkSortName(a).localeCompare(starlarkSortName(b)); + }, + }; - // Category filter - if (st.filterCategory) { - const cat = st.filterCategory.toLowerCase(); - list = list.filter(app => (app.category || '').toLowerCase() === cat); + function renderInstalledCycleButton(btn, value) { + if (value === true) { + btn.innerHTML = 'Installed'; + btn.classList.add('border-green-400', 'bg-green-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); + } else if (value === false) { + btn.innerHTML = 'Not Installed'; + btn.classList.add('border-red-400', 'bg-red-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); + } else { + btn.innerHTML = 'All'; + btn.classList.add('border-gray-300', 'bg-white'); + btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); } + } - // Author filter - if (st.filterAuthor) { - list = list.filter(app => app.author === st.filterAuthor); - } + let _starlarkFilter = null; - // Installed filter - if (st.filterInstalled === true) { - list = list.filter(app => isStarlarkInstalled(app.id)); - } else if (st.filterInstalled === false) { - list = list.filter(app => !isStarlarkInstalled(app.id)); + function getStarlarkFilter() { + if (_starlarkFilter) return _starlarkFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[STARLARK] ListFilter helper unavailable — filters disabled'); + return null; } - - // Sort - list.sort((a, b) => { - const nameA = (a.name || a.id || '').toLowerCase(); - const nameB = (b.name || b.id || '').toLowerCase(); - switch (st.sort) { - case 'z-a': return nameB.localeCompare(nameA); - case 'category': { - const catCmp = (a.category || '').localeCompare(b.category || ''); - return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); - } - case 'author': { - const authCmp = (a.author || '').localeCompare(b.author || ''); - return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); - } - default: return nameA.localeCompare(nameB); // a-z - } + _starlarkFilter = window.ListFilter.create({ + getItems: () => starlarkFullCache || [], + render: (pageApps) => renderStarlarkApps(pageApps, document.getElementById('starlark-apps-grid')), + search: { + el: 'starlark-search', + fields: ['name', 'summary', 'desc', 'author', 'id', 'category'], + debounceMs: 300, + }, + sort: { + el: 'starlark-sort', + default: 'a-z', + comparators: STARLARK_COMPARATORS, + }, + controls: [ + { + type: 'select', + el: 'starlark-category', + key: 'filterCategory', + default: '', + test: (app, value) => (app.category || '').toLowerCase() === value.toLowerCase(), + }, + { + // Author matching is exact/case-sensitive — the options come + // straight from the server's author list. + type: 'select', + el: 'starlark-filter-author', + key: 'filterAuthor', + default: '', + test: (app, value) => app.author === value, + }, + { + type: 'cycle', + el: 'starlark-filter-installed', + key: 'filterInstalled', + default: null, + values: [null, true, false], + test: (app, value) => (value === true ? isStarlarkInstalled(app.id) : !isStarlarkInstalled(app.id)), + render: renderInstalledCycleButton, + }, + ], + pagination: { + perPageEl: 'starlark-per-page', + defaultPerPage: 24, + topEl: 'starlark-pagination-top', + bottomEl: 'starlark-pagination-bottom', + infoEl: 'starlark-results-info', + infoBottomEl: 'starlark-results-info-bottom', + scrollToEl: 'starlark-apps-grid', + infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} apps`, + emptyText: 'No apps match your filters', + }, + persist: { + read: () => ({ + sort: safeLocalStorage.getItem('starlarkSort') || undefined, + perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || undefined, + }), + write: (state) => { + safeLocalStorage.setItem('starlarkSort', state.sort); + safeLocalStorage.setItem('starlarkPerPage', state.perPage); + }, + }, + clearEl: 'starlark-clear-filters', + activeCountEl: 'starlark-active-filters', }); - - starlarkFilteredList = list; - if (!skipPageReset) st.page = 1; - - renderStarlarkPage(); - updateStarlarkFilterUI(); + return _starlarkFilter; } - // ── Render Current Page ───────────────────────────────────────────────── - function renderStarlarkPage() { - const st = starlarkFilterState; - const total = starlarkFilteredList.length; - const totalPages = Math.max(1, Math.ceil(total / st.perPage)); - if (st.page > totalPages) st.page = totalPages; - - const start = (st.page - 1) * st.perPage; - const end = Math.min(start + st.perPage, total); - const pageApps = starlarkFilteredList.slice(start, end); - - // Results info - const info = total > 0 - ? `Showing ${start + 1}\u2013${end} of ${total} apps` - : 'No apps match your filters'; - const infoEl = document.getElementById('starlark-results-info'); - const infoElBot = document.getElementById('starlark-results-info-bottom'); - if (infoEl) infoEl.textContent = info; - if (infoElBot) infoElBot.textContent = info; - - // Pagination - renderStarlarkPagination('starlark-pagination-top', totalPages, st.page); - renderStarlarkPagination('starlark-pagination-bottom', totalPages, st.page); - - // Grid - const grid = document.getElementById('starlark-apps-grid'); - renderStarlarkApps(pageApps, grid); - } - - // ── Pagination Controls ───────────────────────────────────────────────── - function renderStarlarkPagination(containerId, totalPages, currentPage) { - const container = document.getElementById(containerId); - if (!container) return; - - if (totalPages <= 1) { container.innerHTML = ''; return; } - - const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; - const activeClass = 'bg-blue-600 text-white border-blue-600'; - const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; - const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; - - let html = ''; - - // Prev - html += ``; - - // Page numbers with ellipsis - const pages = []; - pages.push(1); - if (currentPage > 3) pages.push('...'); - for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { - pages.push(i); + function applyStarlarkFiltersAndSort(skipPageReset) { + if (!starlarkFullCache) return; + const ctl = getStarlarkFilter(); + if (ctl) { + ctl.apply(skipPageReset); + return; } - if (currentPage < totalPages - 2) pages.push('...'); - if (totalPages > 1) pages.push(totalPages); - - pages.forEach(p => { - if (p === '...') { - html += ``; - } else { - html += ``; - } - }); - - // Next - html += ``; - - container.innerHTML = html; - - // Event delegation for page buttons - container.querySelectorAll('[data-starlark-page]').forEach(btn => { - btn.addEventListener('click', function() { - const p = parseInt(this.getAttribute('data-starlark-page')); - if (p >= 1 && p <= totalPages && p !== currentPage) { - starlarkFilterState.page = p; - renderStarlarkPage(); - // Scroll to top of grid - const grid = document.getElementById('starlark-apps-grid'); - if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }); - }); + renderStarlarkApps(starlarkFullCache, document.getElementById('starlark-apps-grid')); } // ── Card Rendering ────────────────────────────────────────────────────── @@ -5994,127 +5947,12 @@ document.addEventListener('htmx:afterSettle', function() { } // ── Filter UI Updates ─────────────────────────────────────────────────── - function updateStarlarkFilterUI() { - const st = starlarkFilterState; - const count = st.activeCount(); - - const badge = document.getElementById('starlark-active-filters'); - const clearBtn = document.getElementById('starlark-clear-filters'); - if (badge) { - badge.classList.toggle('hidden', count === 0); - badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; - } - if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); - - // Update installed toggle button text - const instBtn = document.getElementById('starlark-filter-installed'); - if (instBtn) { - if (st.filterInstalled === true) { - instBtn.innerHTML = 'Installed'; - instBtn.classList.add('border-green-400', 'bg-green-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); - } else if (st.filterInstalled === false) { - instBtn.innerHTML = 'Not Installed'; - instBtn.classList.add('border-red-400', 'bg-red-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); - } else { - instBtn.innerHTML = 'All'; - instBtn.classList.add('border-gray-300', 'bg-white'); - instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); - } - } - } - // ── Event Listeners ───────────────────────────────────────────────────── function setupStarlarkFilterListeners() { - // Search with debounce - const searchEl = document.getElementById('starlark-search'); - if (searchEl && !searchEl._starlarkInit) { - searchEl._starlarkInit = true; - let debounce = null; - searchEl.addEventListener('input', function() { - clearTimeout(debounce); - debounce = setTimeout(() => { - starlarkFilterState.searchQuery = this.value.trim(); - applyStarlarkFiltersAndSort(); - }, 300); - }); - } - - // Category dropdown - const catEl = document.getElementById('starlark-category'); - if (catEl && !catEl._starlarkInit) { - catEl._starlarkInit = true; - catEl.addEventListener('change', function() { - starlarkFilterState.filterCategory = this.value; - applyStarlarkFiltersAndSort(); - }); - } - - // Sort dropdown - const sortEl = document.getElementById('starlark-sort'); - if (sortEl && !sortEl._starlarkInit) { - sortEl._starlarkInit = true; - sortEl.addEventListener('change', function() { - starlarkFilterState.sort = this.value; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } - - // Author dropdown - const authEl = document.getElementById('starlark-filter-author'); - if (authEl && !authEl._starlarkInit) { - authEl._starlarkInit = true; - authEl.addEventListener('change', function() { - starlarkFilterState.filterAuthor = this.value; - applyStarlarkFiltersAndSort(); - }); - } - - // Installed toggle (cycle: all → installed → not-installed → all) - const instBtn = document.getElementById('starlark-filter-installed'); - if (instBtn && !instBtn._starlarkInit) { - instBtn._starlarkInit = true; - instBtn.addEventListener('click', function() { - const st = starlarkFilterState; - if (st.filterInstalled === null) st.filterInstalled = true; - else if (st.filterInstalled === true) st.filterInstalled = false; - else st.filterInstalled = null; - applyStarlarkFiltersAndSort(); - }); - } - - // Clear filters - const clearBtn = document.getElementById('starlark-clear-filters'); - if (clearBtn && !clearBtn._starlarkInit) { - clearBtn._starlarkInit = true; - clearBtn.addEventListener('click', function() { - starlarkFilterState.reset(); - // Reset UI elements - const searchEl = document.getElementById('starlark-search'); - if (searchEl) searchEl.value = ''; - const catEl = document.getElementById('starlark-category'); - if (catEl) catEl.value = ''; - const sortEl = document.getElementById('starlark-sort'); - if (sortEl) sortEl.value = 'a-z'; - const authEl = document.getElementById('starlark-filter-author'); - if (authEl) authEl.value = ''; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } - - // Per-page selector - const ppEl = document.getElementById('starlark-per-page'); - if (ppEl && !ppEl._starlarkInit) { - ppEl._starlarkInit = true; - ppEl.addEventListener('change', function() { - starlarkFilterState.perPage = parseInt(this.value) || 24; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } + const ctl = getStarlarkFilter(); + if (!ctl) return; + ctl.bind(); + ctl.syncControls(); } // ── Install / Upload / Pixlet ─────────────────────────────────────────── diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 053724b4..87cd0a34 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -974,7 +974,9 @@

- + + + diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index ba7f0c88..d62a8cbc 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -29,6 +29,58 @@

Installed Plugins

0 installed + + +
+ +
+ + + +
+ + +
+ + + + +
+ + + + +
+ + + +
+