mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
feat(plugins): add sorting, filtering, and fix Update All button (#252)
* feat(store): add sorting, filtering, and fix Update All button Add client-side sorting and filtering to the Plugin Store: - Sort by A-Z, Z-A, Verified First, Recently Updated, Category - Filter by verified, new, installed status, author, and tags - Installed/Update Available badges on store cards - Active filter count badge with clear-all button - Sort preference persisted to localStorage Fix three bugs causing button unresponsiveness: - pluginsInitialized never reset on HTMX tab navigation (root cause of Update All silently doing nothing on second visit) - htmx:afterSwap condition too broad (fired on unrelated swaps) - data-running guard tied to DOM element replaced by cloneNode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(store): replace tag pills with category pills, fix sort dates - Replace tag filter pills with category filter pills (less duplication) - Prefer per-plugin last_updated over repo-wide pushed_at for sort Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add console logging to filter/sort handlers * fix: bump cache-buster versions for JS and CSS * feat(plugins): add sorting to installed plugins section Add A-Z, Z-A, and Enabled First sort options for installed plugins with localStorage persistence. Both installed and store sections now default to A-Z sorting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(store): consolidate CSS, fix stale cache bug, add missing utilities, fix icon - Consolidate .filter-pill and .category-filter-pill into shared selectors and scope transition to only changed properties - Fix applyStoreFiltersAndSort ignoring fresh server-filtered results by accepting optional basePlugins parameter - Add missing .py-1.5 and .rounded-full CSS utility classes - Replace invalid fa-sparkles with fa-star (FA 6.0.0 compatible) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(store): semver-aware update badge and add missing gap-1.5 utility - Replace naive version !== comparison with isNewerVersion() that does semver greater-than check, preventing false "Update" badges on same-version or downgrade scenarios - Add missing .gap-1.5 CSS utility used by category pills and tag lists Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,9 @@
|
||||
[data-theme="dark"] .hover\:bg-gray-200:hover { background-color: #4b5563; }
|
||||
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
||||
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
||||
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
||||
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
@@ -141,6 +144,7 @@ body {
|
||||
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
|
||||
.shadow { box-shadow: var(--shadow); }
|
||||
@@ -152,6 +156,7 @@ body {
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.pb-4 { padding-bottom: 1rem; }
|
||||
@@ -199,6 +204,7 @@ body {
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.gap-1\.5 { gap: 0.375rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
@@ -663,6 +669,31 @@ button.bg-white {
|
||||
color: var(--color-purple-text);
|
||||
}
|
||||
|
||||
/* Filter Pill Toggle States */
|
||||
.filter-pill,
|
||||
.category-filter-pill {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-pill[data-active="true"],
|
||||
.category-filter-pill[data-active="true"] {
|
||||
background-color: var(--color-info-bg);
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-pill[data-active="true"]:hover,
|
||||
.category-filter-pill[data-active="true"]:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.category-filter-pill[data-active="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-info);
|
||||
}
|
||||
|
||||
/* Section Headers with Subtle Gradients */
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, rgb(255 255 255 / 90%) 0%, rgb(249 250 251 / 90%) 100%);
|
||||
|
||||
@@ -863,6 +863,43 @@ window.currentPluginConfig = null;
|
||||
let currentOnDemandPluginId = null;
|
||||
let hasLoadedOnDemandStatus = false;
|
||||
|
||||
// Store filter/sort state
|
||||
const storeFilterState = {
|
||||
sort: localStorage.getItem('storeSort') || 'a-z',
|
||||
filterVerified: false,
|
||||
filterNew: false,
|
||||
filterInstalled: null, // null = all, true = installed only, false = not installed only
|
||||
filterAuthors: [],
|
||||
filterCategories: [],
|
||||
|
||||
persist() {
|
||||
localStorage.setItem('storeSort', this.sort);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.sort = 'a-z';
|
||||
this.filterVerified = false;
|
||||
this.filterNew = false;
|
||||
this.filterInstalled = null;
|
||||
this.filterAuthors = [];
|
||||
this.filterCategories = [];
|
||||
this.persist();
|
||||
},
|
||||
|
||||
activeCount() {
|
||||
let count = 0;
|
||||
if (this.filterVerified) count++;
|
||||
if (this.filterNew) count++;
|
||||
if (this.filterInstalled !== null) count++;
|
||||
count += this.filterAuthors.length;
|
||||
count += this.filterCategories.length;
|
||||
return count;
|
||||
}
|
||||
};
|
||||
|
||||
// Installed plugins sort state
|
||||
let installedSort = localStorage.getItem('installedSort') || 'a-z';
|
||||
|
||||
// Shared on-demand status store (mirrors Alpine store when available)
|
||||
window.__onDemandStore = window.__onDemandStore || {
|
||||
loading: true,
|
||||
@@ -976,7 +1013,7 @@ window.initPluginsPage = function() {
|
||||
// If we fetched data before the DOM existed, render it now
|
||||
if (window.__pendingInstalledPlugins) {
|
||||
console.log('[RENDER] Applying pending installed plugins data');
|
||||
renderInstalledPlugins(window.__pendingInstalledPlugins);
|
||||
sortAndRenderInstalledPlugins(window.__pendingInstalledPlugins);
|
||||
window.__pendingInstalledPlugins = null;
|
||||
}
|
||||
if (window.__pendingStorePlugins) {
|
||||
@@ -1106,12 +1143,14 @@ function initializePluginPageWhenReady() {
|
||||
const target = event.detail.target;
|
||||
// Check if plugins content was swapped in
|
||||
if (target.id === 'plugins-content' ||
|
||||
target.querySelector('#installed-plugins-grid') ||
|
||||
document.getElementById('installed-plugins-grid')) {
|
||||
target.querySelector('[data-plugins-loaded]')) {
|
||||
console.log('HTMX swap detected for plugins, initializing...');
|
||||
// Reset initialization flag to allow re-initialization after HTMX swap
|
||||
// Reset initialization flags to allow re-initialization after HTMX swap
|
||||
window.pluginManager.initialized = false;
|
||||
window.pluginManager.initializing = false;
|
||||
pluginsInitialized = false;
|
||||
pluginLoadCache.data = null;
|
||||
pluginLoadCache.promise = null;
|
||||
initTimer = setTimeout(attemptInit, 100);
|
||||
}
|
||||
}, { once: false }); // Allow multiple swaps
|
||||
@@ -1173,6 +1212,9 @@ function initializePlugins() {
|
||||
categorySelect.addEventListener('change', searchPluginStore);
|
||||
}
|
||||
|
||||
// Setup store sort/filter controls
|
||||
setupStoreFilterListeners();
|
||||
|
||||
// Setup GitHub installation handlers
|
||||
console.log('[initializePlugins] About to call setupGitHubInstallHandlers...');
|
||||
if (typeof setupGitHubInstallHandlers === 'function') {
|
||||
@@ -1227,7 +1269,7 @@ function loadInstalledPlugins(forceRefresh = false) {
|
||||
}));
|
||||
pluginLog('[CACHE] Dispatched pluginsUpdated event from cache');
|
||||
// Still render to ensure UI is updated
|
||||
renderInstalledPlugins(pluginLoadCache.data);
|
||||
sortAndRenderInstalledPlugins(pluginLoadCache.data);
|
||||
return Promise.resolve(pluginLoadCache.data);
|
||||
}
|
||||
|
||||
@@ -1286,7 +1328,7 @@ function loadInstalledPlugins(forceRefresh = false) {
|
||||
});
|
||||
}
|
||||
|
||||
renderInstalledPlugins(installedPlugins);
|
||||
sortAndRenderInstalledPlugins(installedPlugins);
|
||||
|
||||
// Update count
|
||||
const countEl = document.getElementById('installed-count');
|
||||
@@ -1327,6 +1369,24 @@ function refreshInstalledPlugins() {
|
||||
window.pluginManager.loadInstalledPlugins = loadInstalledPlugins;
|
||||
// Note: searchPluginStore will be exposed after its definition (see below)
|
||||
|
||||
function sortAndRenderInstalledPlugins(plugins) {
|
||||
const sorted = [...plugins].sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
switch (installedSort) {
|
||||
case 'z-a':
|
||||
return nameB.localeCompare(nameA);
|
||||
case 'enabled':
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
|
||||
return nameA.localeCompare(nameB);
|
||||
case 'a-z':
|
||||
default:
|
||||
return nameA.localeCompare(nameB);
|
||||
}
|
||||
});
|
||||
renderInstalledPlugins(sorted);
|
||||
}
|
||||
|
||||
function renderInstalledPlugins(plugins) {
|
||||
const container = document.getElementById('installed-plugins-grid');
|
||||
if (!container) {
|
||||
@@ -1682,6 +1742,8 @@ function startOnDemandStatusPolling() {
|
||||
|
||||
window.loadOnDemandStatus = loadOnDemandStatus;
|
||||
|
||||
let updateAllRunning = false;
|
||||
|
||||
async function runUpdateAllPlugins() {
|
||||
console.log('[runUpdateAllPlugins] Button clicked, checking for updates...');
|
||||
const button = document.getElementById('update-all-plugins-btn');
|
||||
@@ -1691,7 +1753,7 @@ async function runUpdateAllPlugins() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.running === 'true') {
|
||||
if (updateAllRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1702,7 +1764,7 @@ async function runUpdateAllPlugins() {
|
||||
}
|
||||
|
||||
const originalContent = button.innerHTML;
|
||||
button.dataset.running = 'true';
|
||||
updateAllRunning = true;
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-60', 'cursor-wait');
|
||||
|
||||
@@ -1712,7 +1774,11 @@ async function runUpdateAllPlugins() {
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i];
|
||||
const pluginId = plugin.id;
|
||||
button.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${i + 1}/${plugins.length}...`;
|
||||
// Re-fetch button in case DOM was replaced by HTMX swap
|
||||
const btn = document.getElementById('update-all-plugins-btn');
|
||||
if (btn) {
|
||||
btn.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${i + 1}/${plugins.length}...`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v3/plugins/update', {
|
||||
@@ -1752,10 +1818,13 @@ async function runUpdateAllPlugins() {
|
||||
console.error('Bulk plugin update failed:', error);
|
||||
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
||||
} finally {
|
||||
button.innerHTML = originalContent;
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-60', 'cursor-wait');
|
||||
button.dataset.running = 'false';
|
||||
updateAllRunning = false;
|
||||
const btn = document.getElementById('update-all-plugins-btn');
|
||||
if (btn) {
|
||||
btn.innerHTML = originalContent;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-60', 'cursor-wait');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5037,7 +5106,7 @@ function handleUninstallSuccess(pluginId) {
|
||||
if (typeof installedPlugins !== 'undefined') {
|
||||
installedPlugins = updatedPlugins;
|
||||
}
|
||||
renderInstalledPlugins(updatedPlugins);
|
||||
sortAndRenderInstalledPlugins(updatedPlugins);
|
||||
showNotification(`Plugin uninstalled successfully`, 'success');
|
||||
|
||||
// Also refresh from server to ensure consistency
|
||||
@@ -5076,6 +5145,257 @@ function restartDisplay() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Store Filter/Sort Functions ---
|
||||
|
||||
function setupStoreFilterListeners() {
|
||||
// Sort dropdown
|
||||
const sortSelect = document.getElementById('store-sort');
|
||||
if (sortSelect && !sortSelect._listenerSetup) {
|
||||
sortSelect._listenerSetup = true;
|
||||
sortSelect.value = storeFilterState.sort;
|
||||
sortSelect.addEventListener('change', () => {
|
||||
storeFilterState.sort = sortSelect.value;
|
||||
storeFilterState.persist();
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Verified filter toggle
|
||||
const verifiedBtn = document.getElementById('filter-verified');
|
||||
if (verifiedBtn && !verifiedBtn._listenerSetup) {
|
||||
verifiedBtn._listenerSetup = true;
|
||||
verifiedBtn.addEventListener('click', () => {
|
||||
storeFilterState.filterVerified = !storeFilterState.filterVerified;
|
||||
verifiedBtn.dataset.active = storeFilterState.filterVerified;
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// New filter toggle
|
||||
const newBtn = document.getElementById('filter-new');
|
||||
if (newBtn && !newBtn._listenerSetup) {
|
||||
newBtn._listenerSetup = true;
|
||||
newBtn.addEventListener('click', () => {
|
||||
storeFilterState.filterNew = !storeFilterState.filterNew;
|
||||
newBtn.dataset.active = storeFilterState.filterNew;
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Installed filter (cycles: All -> Installed -> Not Installed -> All)
|
||||
const installedBtn = document.getElementById('filter-installed');
|
||||
if (installedBtn && !installedBtn._listenerSetup) {
|
||||
installedBtn._listenerSetup = true;
|
||||
installedBtn.addEventListener('click', () => {
|
||||
const states = [null, true, false];
|
||||
const labels = ['All', 'Installed', 'Not Installed'];
|
||||
const icons = ['fa-download', 'fa-check', 'fa-times'];
|
||||
const current = states.indexOf(storeFilterState.filterInstalled);
|
||||
const next = (current + 1) % states.length;
|
||||
storeFilterState.filterInstalled = states[next];
|
||||
installedBtn.querySelector('span').textContent = labels[next];
|
||||
installedBtn.querySelector('i').className = `fas ${icons[next]} mr-1`;
|
||||
installedBtn.dataset.active = String(states[next] !== null);
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Author dropdown
|
||||
const authorSelect = document.getElementById('filter-author');
|
||||
if (authorSelect && !authorSelect._listenerSetup) {
|
||||
authorSelect._listenerSetup = true;
|
||||
authorSelect.addEventListener('change', () => {
|
||||
storeFilterState.filterAuthors = authorSelect.value
|
||||
? [authorSelect.value] : [];
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Tag pills (event delegation on container)
|
||||
// Category pills (event delegation on container)
|
||||
const catsPills = document.getElementById('filter-categories-pills');
|
||||
if (catsPills && !catsPills._listenerSetup) {
|
||||
catsPills._listenerSetup = true;
|
||||
catsPills.addEventListener('click', (e) => {
|
||||
const pill = e.target.closest('.category-filter-pill');
|
||||
if (!pill) return;
|
||||
const cat = pill.dataset.category;
|
||||
const idx = storeFilterState.filterCategories.indexOf(cat);
|
||||
if (idx >= 0) {
|
||||
storeFilterState.filterCategories.splice(idx, 1);
|
||||
pill.dataset.active = 'false';
|
||||
} else {
|
||||
storeFilterState.filterCategories.push(cat);
|
||||
pill.dataset.active = 'true';
|
||||
}
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Clear filters button
|
||||
const clearBtn = document.getElementById('clear-filters-btn');
|
||||
if (clearBtn && !clearBtn._listenerSetup) {
|
||||
clearBtn._listenerSetup = true;
|
||||
clearBtn.addEventListener('click', () => {
|
||||
storeFilterState.reset();
|
||||
// Reset all UI elements
|
||||
const sort = document.getElementById('store-sort');
|
||||
if (sort) sort.value = 'a-z';
|
||||
const vBtn = document.getElementById('filter-verified');
|
||||
if (vBtn) vBtn.dataset.active = 'false';
|
||||
const nBtn = document.getElementById('filter-new');
|
||||
if (nBtn) nBtn.dataset.active = 'false';
|
||||
const iBtn = document.getElementById('filter-installed');
|
||||
if (iBtn) {
|
||||
iBtn.dataset.active = 'false';
|
||||
const span = iBtn.querySelector('span');
|
||||
if (span) span.textContent = 'All';
|
||||
const icon = iBtn.querySelector('i');
|
||||
if (icon) icon.className = 'fas fa-download mr-1';
|
||||
}
|
||||
const auth = document.getElementById('filter-author');
|
||||
if (auth) auth.value = '';
|
||||
document.querySelectorAll('.category-filter-pill').forEach(p => {
|
||||
p.dataset.active = 'false';
|
||||
});
|
||||
applyStoreFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Installed plugins sort dropdown
|
||||
const installedSortSelect = document.getElementById('installed-sort');
|
||||
if (installedSortSelect && !installedSortSelect._listenerSetup) {
|
||||
installedSortSelect._listenerSetup = true;
|
||||
installedSortSelect.value = installedSort;
|
||||
installedSortSelect.addEventListener('change', () => {
|
||||
installedSort = installedSortSelect.value;
|
||||
localStorage.setItem('installedSort', installedSort);
|
||||
const plugins = window.installedPlugins || [];
|
||||
if (plugins.length > 0) {
|
||||
sortAndRenderInstalledPlugins(plugins);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyStoreFiltersAndSort(basePlugins) {
|
||||
const source = basePlugins || pluginStoreCache;
|
||||
if (!source) return;
|
||||
|
||||
let plugins = [...source];
|
||||
const installedIds = new Set(
|
||||
(window.installedPlugins || []).map(p => p.id)
|
||||
);
|
||||
|
||||
// Apply filters
|
||||
if (storeFilterState.filterVerified) {
|
||||
plugins = plugins.filter(p => p.verified);
|
||||
}
|
||||
if (storeFilterState.filterNew) {
|
||||
plugins = plugins.filter(p => isNewPlugin(p.last_updated));
|
||||
}
|
||||
if (storeFilterState.filterInstalled === true) {
|
||||
plugins = plugins.filter(p => installedIds.has(p.id));
|
||||
} else if (storeFilterState.filterInstalled === false) {
|
||||
plugins = plugins.filter(p => !installedIds.has(p.id));
|
||||
}
|
||||
if (storeFilterState.filterAuthors.length > 0) {
|
||||
const authorSet = new Set(storeFilterState.filterAuthors);
|
||||
plugins = plugins.filter(p => authorSet.has(p.author));
|
||||
}
|
||||
if (storeFilterState.filterCategories.length > 0) {
|
||||
const catSet = new Set(storeFilterState.filterCategories);
|
||||
plugins = plugins.filter(p => catSet.has(p.category));
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
switch (storeFilterState.sort) {
|
||||
case 'a-z':
|
||||
plugins.sort((a, b) =>
|
||||
(a.name || a.id).localeCompare(b.name || b.id));
|
||||
break;
|
||||
case 'z-a':
|
||||
plugins.sort((a, b) =>
|
||||
(b.name || b.id).localeCompare(a.name || a.id));
|
||||
break;
|
||||
case 'verified':
|
||||
plugins.sort((a, b) =>
|
||||
(b.verified ? 1 : 0) - (a.verified ? 1 : 0) ||
|
||||
(a.name || a.id).localeCompare(b.name || b.id));
|
||||
break;
|
||||
case 'newest':
|
||||
plugins.sort((a, b) => {
|
||||
// Prefer static per-plugin last_updated over GitHub pushed_at (which is repo-wide)
|
||||
const dateA = a.last_updated || a.last_updated_iso || '';
|
||||
const dateB = b.last_updated || b.last_updated_iso || '';
|
||||
return dateB.localeCompare(dateA);
|
||||
});
|
||||
break;
|
||||
case 'category':
|
||||
plugins.sort((a, b) =>
|
||||
(a.category || '').localeCompare(b.category || '') ||
|
||||
(a.name || a.id).localeCompare(b.name || b.id));
|
||||
break;
|
||||
}
|
||||
|
||||
renderPluginStore(plugins);
|
||||
|
||||
// Update result count
|
||||
const countEl = document.getElementById('store-count');
|
||||
if (countEl) {
|
||||
const total = source.length;
|
||||
const shown = plugins.length;
|
||||
countEl.innerHTML = shown < total
|
||||
? `${shown} of ${total} shown`
|
||||
: `${total} available`;
|
||||
}
|
||||
|
||||
updateFilterCountBadge();
|
||||
}
|
||||
|
||||
function populateFilterControls() {
|
||||
if (!pluginStoreCache) return;
|
||||
|
||||
// Collect unique authors
|
||||
const authors = [...new Set(
|
||||
pluginStoreCache.map(p => p.author).filter(Boolean)
|
||||
)].sort();
|
||||
|
||||
const authorSelect = document.getElementById('filter-author');
|
||||
if (authorSelect) {
|
||||
const currentVal = authorSelect.value;
|
||||
authorSelect.innerHTML = '<option value="">All Authors</option>' +
|
||||
authors.map(a => `<option value="${escapeHtml(a)}">${escapeHtml(a)}</option>`).join('');
|
||||
authorSelect.value = currentVal;
|
||||
}
|
||||
|
||||
// Collect unique categories sorted alphabetically
|
||||
const categories = [...new Set(
|
||||
pluginStoreCache.map(p => p.category).filter(Boolean)
|
||||
)].sort();
|
||||
|
||||
const catsContainer = document.getElementById('filter-categories-container');
|
||||
const catsPills = document.getElementById('filter-categories-pills');
|
||||
if (catsContainer && catsPills && categories.length > 0) {
|
||||
catsContainer.classList.remove('hidden');
|
||||
catsPills.innerHTML = categories.map(cat =>
|
||||
`<button class="category-filter-pill badge badge-info cursor-pointer" data-category="${escapeHtml(cat)}" data-active="${storeFilterState.filterCategories.includes(cat)}">${escapeHtml(cat)}</button>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterCountBadge() {
|
||||
const count = storeFilterState.activeCount();
|
||||
const clearBtn = document.getElementById('clear-filters-btn');
|
||||
const badge = document.getElementById('filter-count-badge');
|
||||
if (clearBtn) {
|
||||
clearBtn.classList.toggle('hidden', count === 0);
|
||||
}
|
||||
if (badge) {
|
||||
badge.textContent = count;
|
||||
}
|
||||
}
|
||||
|
||||
function searchPluginStore(fetchCommitInfo = true) {
|
||||
pluginLog('[STORE] Searching plugin store...', { fetchCommitInfo });
|
||||
|
||||
@@ -5100,15 +5420,7 @@ function searchPluginStore(fetchCommitInfo = true) {
|
||||
console.error('plugin-store-grid element not found, cannot render cached plugins');
|
||||
// Don't return, let it fetch fresh data
|
||||
} else {
|
||||
renderPluginStore(pluginStoreCache);
|
||||
try {
|
||||
const countEl = document.getElementById('store-count');
|
||||
if (countEl) {
|
||||
countEl.innerHTML = `${pluginStoreCache.length} available`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not update store count:', e);
|
||||
}
|
||||
applyStoreFiltersAndSort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -5158,6 +5470,7 @@ function searchPluginStore(fetchCommitInfo = true) {
|
||||
pluginStoreCache = plugins;
|
||||
cacheTimestamp = Date.now();
|
||||
console.log('Cached plugin store data');
|
||||
populateFilterControls();
|
||||
}
|
||||
|
||||
// Ensure plugin store grid exists before rendering
|
||||
@@ -5169,17 +5482,9 @@ function searchPluginStore(fetchCommitInfo = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPluginStore(plugins);
|
||||
|
||||
// Update count - safely check element exists
|
||||
try {
|
||||
const countEl = document.getElementById('store-count');
|
||||
if (countEl) {
|
||||
countEl.innerHTML = `${plugins.length} available`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not update store count:', e);
|
||||
}
|
||||
// Route through filter/sort pipeline — pass fresh plugins
|
||||
// so server-filtered results (query/category) aren't ignored
|
||||
applyStoreFiltersAndSort(plugins);
|
||||
|
||||
// Ensure GitHub token collapse handler is attached after store is rendered
|
||||
// The button might not exist until the store content is loaded
|
||||
@@ -5279,14 +5584,26 @@ function renderPluginStore(plugins) {
|
||||
return JSON.stringify(text || '');
|
||||
};
|
||||
|
||||
container.innerHTML = plugins.map(plugin => `
|
||||
// Build installed lookup for badges
|
||||
const installedMap = new Map();
|
||||
(window.installedPlugins || []).forEach(p => {
|
||||
installedMap.set(p.id, p.version || '');
|
||||
});
|
||||
|
||||
container.innerHTML = plugins.map(plugin => {
|
||||
const isInstalled = installedMap.has(plugin.id);
|
||||
const installedVersion = installedMap.get(plugin.id);
|
||||
const hasUpdate = isInstalled && plugin.version && installedVersion && isNewerVersion(plugin.version, installedVersion);
|
||||
return `
|
||||
<div class="plugin-card">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-2 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
||||
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
||||
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
|
||||
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-star mr-1"></i>New</span>' : ''}
|
||||
${isInstalled ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
|
||||
${hasUpdate ? '<span class="badge badge-warning"><i class="fas fa-arrow-up mr-1"></i>Update</span>' : ''}
|
||||
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
||||
@@ -5325,7 +5642,8 @@ function renderPluginStore(plugins) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Expose functions to window for onclick handlers
|
||||
@@ -6036,6 +6354,20 @@ function formatCommit(commit, branch) {
|
||||
return 'Latest';
|
||||
}
|
||||
|
||||
// Check if storeVersion is strictly newer than installedVersion (semver-aware)
|
||||
function isNewerVersion(storeVersion, installedVersion) {
|
||||
const parse = (v) => (v || '').replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const a = parse(storeVersion);
|
||||
const b = parse(installedVersion);
|
||||
const len = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const diff = (a[i] || 0) - (b[i] || 0);
|
||||
if (diff > 0) return true;
|
||||
if (diff < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if plugin is new (updated within last 7 days)
|
||||
function isNewPlugin(lastUpdated) {
|
||||
if (!lastUpdated) return false;
|
||||
|
||||
@@ -1375,7 +1375,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Custom v3 styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
||||
</head>
|
||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
@@ -5013,7 +5013,7 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||
|
||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20250116a" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260216b" defer></script>
|
||||
|
||||
<!-- Custom feeds table helper functions -->
|
||||
<script>
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
<h3 class="text-lg font-bold text-gray-900">Installed Plugins</h3>
|
||||
<span id="installed-count" class="text-sm text-gray-500 font-medium">0 installed</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="installed-sort" class="text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||
<i class="fas fa-sort mr-1"></i>Sort:
|
||||
</label>
|
||||
<select id="installed-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="a-z">A → Z</option>
|
||||
<option value="z-a">Z → A</option>
|
||||
<option value="enabled">Enabled First</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="installed-plugins-content" class="block">
|
||||
<div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
@@ -165,6 +175,68 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort & Filter Controls -->
|
||||
<div id="store-filter-bar" class="mb-4 space-y-3">
|
||||
<!-- Row 1: Sort + Quick Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="store-sort" class="text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||
<i class="fas fa-sort mr-1"></i>Sort:
|
||||
</label>
|
||||
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="a-z">A → Z</option>
|
||||
<option value="z-a">Z → A</option>
|
||||
<option value="verified">Verified First</option>
|
||||
<option value="newest">Recently Updated</option>
|
||||
<option value="category">Category</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="h-6 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- Quick Filter Toggles -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-gray-700">Filter:</span>
|
||||
<button id="filter-verified" type="button" class="filter-pill text-xs px-3 py-1.5 rounded-full border border-gray-300 bg-white hover:bg-gray-50 transition-colors" data-active="false">
|
||||
<i class="fas fa-check-circle mr-1"></i>Verified
|
||||
</button>
|
||||
<button id="filter-new" type="button" class="filter-pill text-xs px-3 py-1.5 rounded-full border border-gray-300 bg-white hover:bg-gray-50 transition-colors" data-active="false">
|
||||
<i class="fas fa-star mr-1"></i>New
|
||||
</button>
|
||||
<button id="filter-installed" type="button" class="filter-pill text-xs px-3 py-1.5 rounded-full border border-gray-300 bg-white hover:bg-gray-50 transition-colors" data-active="false">
|
||||
<i class="fas fa-download mr-1"></i><span>All</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="h-6 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- Author Dropdown -->
|
||||
<select id="filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Authors</option>
|
||||
</select>
|
||||
|
||||
<!-- Clear Filters + Badge -->
|
||||
<button id="clear-filters-btn" type="button" class="hidden text-xs px-3 py-1.5 rounded-full bg-red-100 text-red-700 hover:bg-red-200 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-1"></i>Clear Filters
|
||||
<span id="filter-count-badge" class="ml-1 inline-flex items-center justify-center bg-red-600 text-white rounded-full w-5 h-5 text-xs font-bold">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Category Pills (populated dynamically) -->
|
||||
<div id="filter-categories-container" class="hidden">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-medium text-gray-600 whitespace-nowrap">Categories:</span>
|
||||
<div id="filter-categories-pills" class="flex flex-wrap gap-1.5">
|
||||
<!-- Dynamically populated category pills -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="plugin-store-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
<!-- Loading skeleton -->
|
||||
<div class="store-loading col-span-full">
|
||||
|
||||
Reference in New Issue
Block a user