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 @@