diff --git a/plugin-repos/starlark-apps/tronbyte_repository.py b/plugin-repos/starlark-apps/tronbyte_repository.py index 7795a15a..b34f172b 100644 --- a/plugin-repos/starlark-apps/tronbyte_repository.py +++ b/plugin-repos/starlark-apps/tronbyte_repository.py @@ -6,13 +6,19 @@ Fetches app listings, metadata, and downloads .star files. """ import logging +import time import requests import yaml from typing import Dict, Any, Optional, List, Tuple from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed logger = logging.getLogger(__name__) +# Module-level cache for bulk app listing (survives across requests) +_apps_cache = {'data': None, 'timestamp': 0, 'categories': [], 'authors': []} +_CACHE_TTL = 7200 # 2 hours + class TronbyteRepository: """ @@ -232,6 +238,102 @@ class TronbyteRepository: return apps_with_metadata + def list_all_apps_cached(self) -> Dict[str, Any]: + """ + Fetch ALL apps with metadata, using a module-level cache. + + On first call (or after cache TTL expires), fetches the directory listing + via the GitHub API (1 call) then fetches all manifests in parallel via + raw.githubusercontent.com (not rate-limited). Results are cached for 2 hours. + + Returns: + Dict with keys: apps, categories, authors, count, cached + """ + global _apps_cache + + now = time.time() + if _apps_cache['data'] is not None and (now - _apps_cache['timestamp']) < _CACHE_TTL: + return { + 'apps': _apps_cache['data'], + 'categories': _apps_cache['categories'], + 'authors': _apps_cache['authors'], + 'count': len(_apps_cache['data']), + 'cached': True + } + + # Fetch directory listing (1 GitHub API call) + success, app_dirs, error = self.list_apps() + if not success or not app_dirs: + logger.error(f"Failed to list apps for bulk fetch: {error}") + return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False} + + logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...") + + def fetch_one(app_info): + """Fetch a single app's manifest (runs in thread pool).""" + app_id = app_info['id'] + manifest_path = f"{self.APPS_PATH}/{app_id}/manifest.yaml" + content = self._fetch_raw_file(manifest_path) + if content: + try: + metadata = yaml.safe_load(content) + if not isinstance(metadata, dict): + metadata = {} + metadata['id'] = app_id + metadata['repository_path'] = app_info.get('path', '') + return metadata + except (yaml.YAMLError, TypeError): + pass + # Fallback: minimal entry + return { + 'id': app_id, + 'name': app_id.replace('_', ' ').replace('-', ' ').title(), + 'summary': 'No description available', + 'repository_path': app_info.get('path', ''), + } + + # Parallel manifest fetches via raw.githubusercontent.com (high rate limit) + apps_with_metadata = [] + with ThreadPoolExecutor(max_workers=20) as executor: + futures = {executor.submit(fetch_one, info): info for info in app_dirs} + for future in as_completed(futures): + try: + result = future.result(timeout=30) + if result: + apps_with_metadata.append(result) + except Exception as e: + app_info = futures[future] + logger.warning(f"Failed to fetch manifest for {app_info['id']}: {e}") + apps_with_metadata.append({ + 'id': app_info['id'], + 'name': app_info['id'].replace('_', ' ').replace('-', ' ').title(), + 'summary': 'No description available', + 'repository_path': app_info.get('path', ''), + }) + + # Sort by name for consistent ordering + apps_with_metadata.sort(key=lambda a: (a.get('name') or a.get('id', '')).lower()) + + # Extract unique categories and authors + categories = sorted({a.get('category', '') for a in apps_with_metadata if a.get('category')}) + authors = sorted({a.get('author', '') for a in apps_with_metadata if a.get('author')}) + + # Update cache + _apps_cache['data'] = apps_with_metadata + _apps_cache['timestamp'] = now + _apps_cache['categories'] = categories + _apps_cache['authors'] = authors + + logger.info(f"Cached {len(apps_with_metadata)} apps ({len(categories)} categories, {len(authors)} authors)") + + return { + 'apps': apps_with_metadata, + 'categories': categories, + 'authors': authors, + 'count': len(apps_with_metadata), + 'cached': False + } + def download_star_file(self, app_id: str, output_path: Path) -> Tuple[bool, Optional[str]]: """ Download the .star file for an app. diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index cfafe0d4..52f16a83 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7469,7 +7469,12 @@ def render_starlark_app(app_id): @api_v3.route('/starlark/repository/browse', methods=['GET']) def browse_tronbyte_repository(): - """Browse apps in the Tronbyte repository.""" + """Browse all apps in the Tronbyte repository (bulk cached fetch). + + Returns ALL apps with metadata, categories, and authors. + Filtering/sorting/pagination is handled client-side. + Results are cached server-side for 2 hours. + """ try: TronbyteRepository = _get_tronbyte_repository_class() @@ -7477,24 +7482,18 @@ def browse_tronbyte_repository(): github_token = config.get('github_token') repo = TronbyteRepository(github_token=github_token) - search_query = request.args.get('search', '') - category = request.args.get('category', 'all') - limit = max(1, min(request.args.get('limit', 50, type=int), 200)) - - apps = repo.list_apps_with_metadata(max_apps=limit) - if search_query: - apps = repo.search_apps(search_query, apps) - if category and category != 'all': - apps = repo.filter_by_category(category, apps) + result = repo.list_all_apps_cached() rate_limit = repo.get_rate_limit_info() return jsonify({ 'status': 'success', - 'apps': apps, - 'count': len(apps), + 'apps': result['apps'], + 'categories': result['categories'], + 'authors': result['authors'], + 'count': result['count'], + 'cached': result['cached'], 'rate_limit': rate_limit, - 'filters': {'search': search_query, 'category': category} }) except Exception as e: @@ -7574,16 +7573,15 @@ def install_from_tronbyte_repository(): @api_v3.route('/starlark/repository/categories', methods=['GET']) def get_tronbyte_categories(): - """Get list of available app categories.""" + """Get list of available app categories (uses bulk cache).""" try: TronbyteRepository = _get_tronbyte_repository_class() config = api_v3.config_manager.load_config() if api_v3.config_manager else {} repo = TronbyteRepository(github_token=config.get('github_token')) - apps = repo.list_apps_with_metadata(max_apps=100) - categories = sorted({app.get('category', '') for app in apps if app.get('category')}) + result = repo.list_all_apps_cached() - return jsonify({'status': 'success', 'categories': categories}) + return jsonify({'status': 'success', 'categories': result['categories']}) except Exception as e: logger.error(f"Error fetching categories: {e}") diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 60e7a634..d737f8e6 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -859,6 +859,36 @@ 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 = []; + + // ── Plugin Store Filter State ─────────────────────────────────────────── + const storeFilterState = { + sort: localStorage.getItem('storeSort') || 'a-z', + filterCategory: '', + filterInstalled: null, // null=all, true=installed, false=not-installed + searchQuery: '', + page: 1, + perPage: parseInt(localStorage.getItem('storePerPage')) || 12, + persist() { + localStorage.setItem('storeSort', this.sort); + localStorage.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; @@ -981,8 +1011,10 @@ window.initPluginsPage = function() { } if (window.__pendingStorePlugins) { console.log('[RENDER] Applying pending plugin store data'); - renderPluginStore(window.__pendingStorePlugins); + pluginStoreCache = window.__pendingStorePlugins; + cacheTimestamp = Date.now(); window.__pendingStorePlugins = null; + applyStoreFiltersAndSort(); } initializePlugins(); @@ -991,7 +1023,6 @@ window.initPluginsPage = function() { const refreshBtn = document.getElementById('refresh-plugins-btn'); const updateAllBtn = document.getElementById('update-all-plugins-btn'); const restartBtn = document.getElementById('restart-display-btn'); - const searchBtn = document.getElementById('search-plugins-btn'); const closeBtn = document.getElementById('close-plugin-config'); const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal'); const cancelOnDemandBtn = document.getElementById('cancel-on-demand'); @@ -1019,10 +1050,13 @@ window.initPluginsPage = function() { document.getElementById('restart-display-btn').addEventListener('click', restartDisplay); console.log('[initPluginsPage] Attached restartDisplay listener'); } - if (searchBtn) { - searchBtn.replaceWith(searchBtn.cloneNode(true)); - document.getElementById('search-plugins-btn').addEventListener('click', searchPluginStore); - } + // 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; + setupStoreFilterListeners(); + if (closeBtn) { closeBtn.replaceWith(closeBtn.cloneNode(true)); document.getElementById('close-plugin-config').addEventListener('click', closePluginConfigModal); @@ -5098,167 +5132,86 @@ function restartDisplay() { function searchPluginStore(fetchCommitInfo = true) { pluginLog('[STORE] Searching plugin store...', { fetchCommitInfo }); - - // Safely get search values (elements may not exist yet) - const searchInput = document.getElementById('plugin-search'); - const categorySelect = document.getElementById('plugin-category'); - const query = searchInput ? searchInput.value : ''; - const category = categorySelect ? categorySelect.value : ''; - // For filtered searches (user typing), we can use cache to avoid excessive API calls - // For initial load or refresh, always fetch fresh metadata - const isFilteredSearch = query || category; const now = Date.now(); const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION); - - // Only use cache for filtered searches that don't explicitly request fresh metadata - if (isFilteredSearch && isCacheValid && !fetchCommitInfo) { - console.log('Using cached plugin store data for filtered search'); - // Ensure plugin store grid exists before rendering + + // If cache is valid and we don't need fresh commit info, just re-filter + if (isCacheValid && !fetchCommitInfo) { + console.log('Using cached plugin store data'); const storeGrid = document.getElementById('plugin-store-grid'); - if (!storeGrid) { - 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); - } + if (storeGrid) { + applyStoreFiltersAndSort(); return; } } - // Show loading state - safely check element exists + // Show loading state try { const countEl = document.getElementById('store-count'); - if (countEl) { - countEl.innerHTML = 'Loading...'; - } - } catch (e) { - console.warn('Could not update store count:', e); - } + if (countEl) countEl.innerHTML = 'Loading...'; + } catch (e) { /* ignore */ } showStoreLoading(true); let url = '/api/v3/plugins/store/list'; - const params = new URLSearchParams(); - if (query) params.append('query', query); - if (category) params.append('category', category); - // Always fetch fresh commit metadata unless explicitly disabled (for performance on repeated filtered searches) if (!fetchCommitInfo) { - params.append('fetch_commit_info', 'false'); - } - // Note: fetch_commit_info defaults to true on the server side to keep metadata fresh - - if (params.toString()) { - url += '?' + params.toString(); + url += '?fetch_commit_info=false'; } console.log('Store URL:', url); fetch(url) - .then(response => { - console.log('Store response:', response.status); - return response.json(); - }) + .then(response => response.json()) .then(data => { - console.log('Store data:', data); showStoreLoading(false); - + if (data.status === 'success') { const plugins = data.data.plugins || []; console.log('Store plugins count:', plugins.length); - - // Cache the results if no filters - if (!query && !category) { - pluginStoreCache = plugins; - cacheTimestamp = Date.now(); - console.log('Cached plugin store data'); - } - - // Ensure plugin store grid exists before rendering + + pluginStoreCache = plugins; + cacheTimestamp = Date.now(); + const storeGrid = document.getElementById('plugin-store-grid'); if (!storeGrid) { - // Defer rendering until plugin tab loads pluginLog('[STORE] plugin-store-grid not ready, deferring render'); window.__pendingStorePlugins = plugins; return; } - - renderPluginStore(plugins); - // Update count - safely check element exists + // Update total count try { const countEl = document.getElementById('store-count'); - if (countEl) { - countEl.innerHTML = `${plugins.length} available`; - } - } catch (e) { - console.warn('Could not update store count:', e); - } - - // Ensure GitHub token collapse handler is attached after store is rendered - // The button might not exist until the store content is loaded - console.log('[STORE] Checking for attachGithubTokenCollapseHandler...', { - exists: typeof window.attachGithubTokenCollapseHandler, - checkGitHubAuthStatus: typeof window.checkGitHubAuthStatus - }); + if (countEl) countEl.innerHTML = `${plugins.length} available`; + } catch (e) { /* ignore */ } + + applyStoreFiltersAndSort(); + + // Re-attach GitHub token collapse handler after store render if (window.attachGithubTokenCollapseHandler) { - // Use requestAnimationFrame for faster execution (runs on next frame, ~16ms) requestAnimationFrame(() => { - console.log('[STORE] Re-attaching GitHub token collapse handler after store render'); - try { - window.attachGithubTokenCollapseHandler(); - } catch (error) { - console.error('[STORE] Error attaching collapse handler:', error); - } - // Also check auth status to update UI (already checked earlier, but refresh to be sure) + try { window.attachGithubTokenCollapseHandler(); } catch (e) { /* ignore */ } if (window.checkGitHubAuthStatus) { - console.log('[STORE] Refreshing GitHub auth status after store render...'); - try { - window.checkGitHubAuthStatus(); - } catch (error) { - console.error('[STORE] Error calling checkGitHubAuthStatus:', error); - } - } else { - console.warn('[STORE] checkGitHubAuthStatus not available'); + try { window.checkGitHubAuthStatus(); } catch (e) { /* ignore */ } } }); - } else { - console.warn('[STORE] attachGithubTokenCollapseHandler not available'); } } else { showError('Failed to search plugin store: ' + data.message); try { const countEl = document.getElementById('store-count'); - if (countEl) { - countEl.innerHTML = 'Error loading'; - } - } catch (e) { - console.warn('Could not update store count:', e); - } + if (countEl) countEl.innerHTML = 'Error loading'; + } catch (e) { /* ignore */ } } }) .catch(error => { console.error('Error searching plugin store:', error); showStoreLoading(false); - let errorMsg = 'Error searching plugin store: ' + error.message; - if (error.message && error.message.includes('Failed to Fetch')) { - errorMsg += ' - Please try refreshing your browser.'; - } - showError(errorMsg); + showError('Error searching plugin store: ' + error.message); try { const countEl = document.getElementById('store-count'); - if (countEl) { - countEl.innerHTML = 'Error loading'; - } - } catch (e) { - console.warn('Could not update store count:', e); - } + if (countEl) countEl.innerHTML = 'Error loading'; + } catch (e) { /* ignore */ } }); } @@ -5269,6 +5222,257 @@ function showStoreLoading(show) { } } +// ── Plugin Store: Client-Side Filter/Sort/Pagination ──────────────────────── + +function isStorePluginInstalled(pluginId) { + return (window.installedPlugins || installedPlugins || []).some(p => p.id === pluginId); +} + +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.id)); + } else if (st.filterInstalled === false) { + list = list.filter(plugin => !isStorePluginInstalled(plugin.id)); + } + + // 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(); +} + +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 renderStorePagination(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 = ''; + 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); + } + 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' }); + } + }); + }); +} + +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 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(); + }); + } +} + // Expose searchPluginStore on window.pluginManager for Alpine.js integration window.searchPluginStore = searchPluginStore; window.pluginManager.searchPluginStore = searchPluginStore; @@ -5299,13 +5503,16 @@ function renderPluginStore(plugins) { return JSON.stringify(text || ''); }; - container.innerHTML = plugins.map(plugin => ` + container.innerHTML = plugins.map(plugin => { + const installed = isStorePluginInstalled(plugin.id); + return `
-
+

${escapeHtml(plugin.name || plugin.id)}

${plugin.verified ? 'Verified' : ''} + ${installed ? 'Installed' : ''} ${isNewPlugin(plugin.last_updated) ? 'New' : ''} ${plugin._source === 'custom_repository' ? `Custom` : ''}
@@ -5326,26 +5533,26 @@ function renderPluginStore(plugins) { ` : ''} -
+
-
-
-
- `).join(''); +
`; + }).join(''); } // Expose functions to window for onclick handlers @@ -5366,12 +5573,9 @@ window.installPlugin = function(pluginId, branch = null) { .then(data => { showNotification(data.message, data.status); if (data.status === 'success') { - // Refresh both installed plugins and store + // Refresh installed plugins list, then re-render store to update badges loadInstalledPlugins(); - // Delay store refresh slightly to ensure DOM is ready - setTimeout(() => { - searchPluginStore(); - }, 100); + setTimeout(() => applyStoreFiltersAndSort(true), 500); } }) .catch(error => { @@ -7323,8 +7527,59 @@ setTimeout(function() { 'use strict'; let starlarkSectionVisible = false; - let starlarkAppsCache = null; + let starlarkFullCache = null; // All apps from server + let starlarkFilteredList = []; // After filters applied + let starlarkDataLoaded = false; + // ── Filter State ──────────────────────────────────────────────────────── + const starlarkFilterState = { + sort: localStorage.getItem('starlarkSort') || 'a-z', + filterInstalled: null, // null=all, true=installed, false=not-installed + filterAuthor: '', + filterCategory: '', + searchQuery: '', + page: 1, + perPage: parseInt(localStorage.getItem('starlarkPerPage')) || 24, + persist() { + localStorage.setItem('starlarkSort', this.sort); + localStorage.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 ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function isStarlarkInstalled(appId) { + // Check window.installedPlugins (populated by loadInstalledPlugins) + if (window.installedPlugins && Array.isArray(window.installedPlugins)) { + return window.installedPlugins.some(p => p.id === 'starlark:' + appId); + } + return false; + } + + // ── Section Toggle + Init ─────────────────────────────────────────────── function initStarlarkSection() { const toggleBtn = document.getElementById('toggle-starlark-section'); if (toggleBtn && !toggleBtn._starlarkInit) { @@ -7341,16 +7596,18 @@ setTimeout(function() { this.querySelector('span').textContent = starlarkSectionVisible ? 'Hide' : 'Show'; if (starlarkSectionVisible) { loadStarlarkStatus(); - loadStarlarkCategories(); + if (!starlarkDataLoaded) fetchStarlarkApps(); } }); } - const browseBtn = document.getElementById('starlark-browse-btn'); - if (browseBtn && !browseBtn._starlarkInit) { - browseBtn._starlarkInit = true; - browseBtn.addEventListener('click', browseStarlarkApps); - } + // 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; + + setupStarlarkFilterListeners(); const uploadBtn = document.getElementById('starlark-upload-btn'); if (uploadBtn && !uploadBtn._starlarkInit) { @@ -7367,6 +7624,7 @@ setTimeout(function() { } } + // ── Status ────────────────────────────────────────────────────────────── function loadStarlarkStatus() { fetch('/api/v3/starlark/status') .then(r => r.json()) @@ -7387,52 +7645,60 @@ setTimeout(function() { .catch(err => console.error('Starlark status error:', err)); } - function loadStarlarkCategories() { - fetch('/api/v3/starlark/repository/categories') - .then(r => r.json()) - .then(data => { - if (data.status !== 'success') return; - const select = document.getElementById('starlark-category'); - if (!select) return; - select.innerHTML = ''; - (data.categories || []).forEach(cat => { - const opt = document.createElement('option'); - opt.value = cat; - opt.textContent = cat; - select.appendChild(opt); - }); - }) - .catch(err => console.error('Starlark categories error:', err)); - } - - function browseStarlarkApps() { - const search = (document.getElementById('starlark-search') || {}).value || ''; - const category = (document.getElementById('starlark-category') || {}).value || ''; + // ── Bulk Fetch All Apps ───────────────────────────────────────────────── + function fetchStarlarkApps() { const grid = document.getElementById('starlark-apps-grid'); - const countEl = document.getElementById('starlark-apps-count'); + if (grid) { + grid.innerHTML = `
+
+ ${Array(10).fill('
').join('')} +
+
`; + } - if (grid) grid.innerHTML = '
Loading Tronbyte apps...
'; - - const params = new URLSearchParams(); - if (search) params.set('search', search); - if (category) params.set('category', category); - params.set('limit', '50'); - - fetch('/api/v3/starlark/repository/browse?' + params.toString()) + fetch('/api/v3/starlark/repository/browse') .then(r => r.json()) .then(data => { if (data.status !== 'success') { if (grid) grid.innerHTML = `
${escapeHtml(data.message || 'Failed to load')}
`; return; } - starlarkAppsCache = data.apps; + + starlarkFullCache = data.apps || []; + starlarkDataLoaded = true; + + // Populate category dropdown + const catSelect = document.getElementById('starlark-category'); + if (catSelect) { + catSelect.innerHTML = ''; + (data.categories || []).forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = cat; + catSelect.appendChild(opt); + }); + } + + // Populate author dropdown + const authSelect = document.getElementById('starlark-filter-author'); + if (authSelect) { + authSelect.innerHTML = ''; + (data.authors || []).forEach(author => { + const opt = document.createElement('option'); + opt.value = author; + opt.textContent = author; + authSelect.appendChild(opt); + }); + } + + const countEl = document.getElementById('starlark-apps-count'); if (countEl) countEl.textContent = `${data.count} apps`; - renderStarlarkApps(data.apps, grid); if (data.rate_limit) { - const rl = data.rate_limit; - console.log(`[Starlark] GitHub rate limit: ${rl.remaining}/${rl.limit} remaining`); + console.log(`[Starlark] GitHub rate limit: ${data.rate_limit.remaining}/${data.rate_limit.limit} remaining` + (data.cached ? ' (cached)' : '')); } + + applyStarlarkFiltersAndSort(); }) .catch(err => { console.error('Starlark browse error:', err); @@ -7440,6 +7706,151 @@ setTimeout(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); + }); + } + + // Category filter + if (st.filterCategory) { + const cat = st.filterCategory.toLowerCase(); + list = list.filter(app => (app.category || '').toLowerCase() === cat); + } + + // Author filter + if (st.filterAuthor) { + list = list.filter(app => app.author === st.filterAuthor); + } + + // 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)); + } + + // 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 + } + }); + + starlarkFilteredList = list; + if (!skipPageReset) st.page = 1; + + renderStarlarkPage(); + updateStarlarkFilterUI(); + } + + // ── 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); + } + 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' }); + } + }); + }); + } + + // ── Card Rendering ────────────────────────────────────────────────────── function renderStarlarkApps(apps, grid) { if (!grid) return; if (!apps || apps.length === 0) { @@ -7447,13 +7858,16 @@ setTimeout(function() { return; } - grid.innerHTML = apps.map(app => ` + grid.innerHTML = apps.map(app => { + const installed = isStarlarkInstalled(app.id); + return `
-
+

${escapeHtml(app.name || app.id)}

Starlark + ${installed ? 'Installed' : ''}
${app.author ? `

${escapeHtml(app.author)}

` : ''} @@ -7462,25 +7876,143 @@ setTimeout(function() {

${escapeHtml(app.summary || app.desc || 'No description')}

-
- -
-
- `).join(''); +
`; + }).join(''); } - function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + // ── 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(); + }); + } + } + + // ── Install / Upload / Pixlet ─────────────────────────────────────────── window.installStarlarkApp = function(appId) { if (!confirm(`Install Starlark app "${appId}" from Tronbyte repository?`)) return; @@ -7493,9 +8025,11 @@ setTimeout(function() { .then(data => { if (data.status === 'success') { alert(`Installed: ${data.message || appId}`); - // Refresh installed plugins to show the new starlark app + // Refresh installed plugins list if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); + // Re-render current page to update installed badges + setTimeout(() => applyStarlarkFiltersAndSort(true), 500); } else { alert(`Install failed: ${data.message || 'Unknown error'}`); } @@ -7537,6 +8071,7 @@ setTimeout(function() { alert(`Uploaded: ${data.app_id}`); if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); + setTimeout(() => applyStarlarkFiltersAndSort(true), 500); } else { alert('Upload failed: ' + (data.message || 'Unknown error')); } @@ -7544,14 +8079,13 @@ setTimeout(function() { .catch(err => alert('Upload failed: ' + err.message)); } - // Initialize when plugins tab loads + // ── Bootstrap ─────────────────────────────────────────────────────────── const origInit = window.initializePlugins; window.initializePlugins = function() { if (origInit) origInit(); initStarlarkSection(); }; - // Also try to init on DOMContentLoaded and on HTMX load document.addEventListener('DOMContentLoaded', initStarlarkSection); document.addEventListener('htmx:afterSwap', function(e) { if (e.detail && e.detail.target && e.detail.target.id === 'plugins-content') { diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index ef155ce8..3b01bd74 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -147,24 +147,61 @@
-
-
- - + +
+ + +
+ + + +
+ + + + +
+ + + + +
+ + +
+ +
+ - +
+
@@ -174,8 +211,14 @@
+
+ + +
+ +
@@ -197,27 +240,74 @@
- +
- - + -
+ + +
+ + + +
+ + + + + + + +
+ + + +
- -
- + +
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +