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:
Chuck
2026-02-17 07:38:16 -05:00
committed by GitHub
parent 963c4d3b91
commit 636d0e181c
4 changed files with 477 additions and 42 deletions

View File

@@ -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%);

View File

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

View File

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

View File

@@ -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 &rarr; Z</option>
<option value="z-a">Z &rarr; 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 &rarr; Z</option>
<option value="z-a">Z &rarr; 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">