mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps
Plugin Store: - Live search with 300ms debounce (replaces Search button) - Sort dropdown: A→Z, Z→A, Category, Author, Newest - Installed toggle filter (All / Installed / Not Installed) - Per-page selector (12/24/48) with pagination controls - "Installed" badge and "Reinstall" button on already-installed plugins - Active filter count badge + clear filters button Starlark Apps: - Parallel bulk manifest fetching via ThreadPoolExecutor (20 workers) - Server-side 2-hour cache for all 500+ Tronbyte app manifests - Auto-loads all apps when section expands (no Browse button) - Live search, sort (A→Z, Z→A, Category, Author), author dropdown - Installed toggle filter, per-page selector (24/48/96), pagination - "Installed" badge on cards, "Reinstall" button variant Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,19 @@ Fetches app listings, metadata, and downloads .star files.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class TronbyteRepository:
|
||||||
"""
|
"""
|
||||||
@@ -232,6 +238,102 @@ class TronbyteRepository:
|
|||||||
|
|
||||||
return apps_with_metadata
|
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]]:
|
def download_star_file(self, app_id: str, output_path: Path) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Download the .star file for an app.
|
Download the .star file for an app.
|
||||||
|
|||||||
@@ -7469,7 +7469,12 @@ def render_starlark_app(app_id):
|
|||||||
|
|
||||||
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
||||||
def browse_tronbyte_repository():
|
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:
|
try:
|
||||||
TronbyteRepository = _get_tronbyte_repository_class()
|
TronbyteRepository = _get_tronbyte_repository_class()
|
||||||
|
|
||||||
@@ -7477,24 +7482,18 @@ def browse_tronbyte_repository():
|
|||||||
github_token = config.get('github_token')
|
github_token = config.get('github_token')
|
||||||
repo = TronbyteRepository(github_token=github_token)
|
repo = TronbyteRepository(github_token=github_token)
|
||||||
|
|
||||||
search_query = request.args.get('search', '')
|
result = repo.list_all_apps_cached()
|
||||||
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)
|
|
||||||
|
|
||||||
rate_limit = repo.get_rate_limit_info()
|
rate_limit = repo.get_rate_limit_info()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'apps': apps,
|
'apps': result['apps'],
|
||||||
'count': len(apps),
|
'categories': result['categories'],
|
||||||
|
'authors': result['authors'],
|
||||||
|
'count': result['count'],
|
||||||
|
'cached': result['cached'],
|
||||||
'rate_limit': rate_limit,
|
'rate_limit': rate_limit,
|
||||||
'filters': {'search': search_query, 'category': category}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -7574,16 +7573,15 @@ def install_from_tronbyte_repository():
|
|||||||
|
|
||||||
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||||
def get_tronbyte_categories():
|
def get_tronbyte_categories():
|
||||||
"""Get list of available app categories."""
|
"""Get list of available app categories (uses bulk cache)."""
|
||||||
try:
|
try:
|
||||||
TronbyteRepository = _get_tronbyte_repository_class()
|
TronbyteRepository = _get_tronbyte_repository_class()
|
||||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
repo = TronbyteRepository(github_token=config.get('github_token'))
|
repo = TronbyteRepository(github_token=config.get('github_token'))
|
||||||
|
|
||||||
apps = repo.list_apps_with_metadata(max_apps=100)
|
result = repo.list_all_apps_cached()
|
||||||
categories = sorted({app.get('category', '') for app in apps if app.get('category')})
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'categories': categories})
|
return jsonify({'status': 'success', 'categories': result['categories']})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching categories: {e}")
|
logger.error(f"Error fetching categories: {e}")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -147,8 +147,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<!-- Search Row -->
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3 mb-4">
|
||||||
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
@@ -160,11 +160,48 @@
|
|||||||
<option value="media">Media</option>
|
<option value="media">Media</option>
|
||||||
<option value="demo">Demo</option>
|
<option value="demo">Demo</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="search-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg whitespace-nowrap font-semibold shadow-sm">
|
</div>
|
||||||
<i class="fas fa-search mr-2"></i>Search
|
|
||||||
|
<!-- Sort & Filter Bar -->
|
||||||
|
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<!-- Sort -->
|
||||||
|
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
|
||||||
|
<option value="a-z">A → Z</option>
|
||||||
|
<option value="z-a">Z → A</option>
|
||||||
|
<option value="category">Category</option>
|
||||||
|
<option value="author">Author</option>
|
||||||
|
<option value="newest">Newest</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="w-px h-6 bg-gray-300"></div>
|
||||||
|
|
||||||
|
<!-- Installed filter toggle -->
|
||||||
|
<button id="store-filter-installed" class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-100 transition-colors" title="Cycle: All → Installed → Not Installed">
|
||||||
|
<i class="fas fa-filter mr-1 text-gray-400"></i>All
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- Active filter count + clear -->
|
||||||
|
<span id="store-active-filters" class="hidden text-xs text-blue-600 font-medium"></span>
|
||||||
|
<button id="store-clear-filters" class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
|
||||||
|
<i class="fas fa-times mr-1"></i>Clear Filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Bar (top pagination) -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<span id="store-results-info" class="text-sm text-gray-600"></span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
|
||||||
|
<option value="12">12 per page</option>
|
||||||
|
<option value="24">24 per page</option>
|
||||||
|
<option value="48">48 per page</option>
|
||||||
|
</select>
|
||||||
|
<div id="store-pagination-top" class="flex items-center gap-1"></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">
|
<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 -->
|
<!-- Loading skeleton -->
|
||||||
<div class="store-loading col-span-full">
|
<div class="store-loading col-span-full">
|
||||||
@@ -177,6 +214,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Pagination -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mt-4">
|
||||||
|
<span id="store-results-info-bottom" class="text-sm text-gray-600"></span>
|
||||||
|
<div id="store-pagination-bottom" class="flex items-center gap-1"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Starlark Apps Section (Tronbyte Community Apps) -->
|
<!-- Starlark Apps Section (Tronbyte Community Apps) -->
|
||||||
@@ -197,27 +240,74 @@
|
|||||||
<!-- Pixlet Status Banner -->
|
<!-- Pixlet Status Banner -->
|
||||||
<div id="starlark-pixlet-status" class="mb-4"></div>
|
<div id="starlark-pixlet-status" class="mb-4"></div>
|
||||||
|
|
||||||
<!-- Search/Filter -->
|
<!-- Search Row -->
|
||||||
<div class="flex gap-3 mb-4">
|
<div class="flex gap-3 mb-4">
|
||||||
<input type="text" id="starlark-search" placeholder="Search Starlark apps..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm">
|
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm">
|
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="starlark-browse-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg whitespace-nowrap font-semibold shadow-sm">
|
</div>
|
||||||
<i class="fas fa-search mr-2"></i>Browse
|
|
||||||
|
<!-- Sort & Filter Bar -->
|
||||||
|
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<!-- Sort -->
|
||||||
|
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
|
||||||
|
<option value="a-z">A → Z</option>
|
||||||
|
<option value="z-a">Z → A</option>
|
||||||
|
<option value="category">Category</option>
|
||||||
|
<option value="author">Author</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="w-px h-6 bg-gray-300"></div>
|
||||||
|
|
||||||
|
<!-- Installed filter toggle -->
|
||||||
|
<button id="starlark-filter-installed" class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-100 transition-colors" title="Cycle: All → Installed → Not Installed">
|
||||||
|
<i class="fas fa-filter mr-1 text-gray-400"></i>All
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Author filter -->
|
||||||
|
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
|
||||||
|
<option value="">All Authors</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- Active filter count + clear -->
|
||||||
|
<span id="starlark-active-filters" class="hidden text-xs text-blue-600 font-medium"></span>
|
||||||
|
<button id="starlark-clear-filters" class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
|
||||||
|
<i class="fas fa-times mr-1"></i>Clear Filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload .star file -->
|
<!-- Results Bar (top pagination) -->
|
||||||
<div class="flex gap-3 mb-4">
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<button id="starlark-upload-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
|
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
|
||||||
<i class="fas fa-upload mr-2"></i>Upload .star File
|
<div class="flex items-center gap-3">
|
||||||
</button>
|
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
|
||||||
|
<option value="24">24 per page</option>
|
||||||
|
<option value="48">48 per page</option>
|
||||||
|
<option value="96">96 per page</option>
|
||||||
|
</select>
|
||||||
|
<div id="starlark-pagination-top" class="flex items-center gap-1"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Starlark Apps Grid -->
|
<!-- Starlark Apps Grid -->
|
||||||
<div id="starlark-apps-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
<div id="starlark-apps-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Pagination -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mt-4">
|
||||||
|
<span id="starlark-results-info-bottom" class="text-sm text-gray-600"></span>
|
||||||
|
<div id="starlark-pagination-bottom" class="flex items-center gap-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload .star file -->
|
||||||
|
<div class="flex gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<button id="starlark-upload-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
|
||||||
|
<i class="fas fa-upload mr-2"></i>Upload .star File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user