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:
Chuck
2026-02-18 14:54:29 -05:00
parent 5f2daa52b0
commit 942663abfd
4 changed files with 954 additions and 230 deletions

View File

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

View File

@@ -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}")

File diff suppressed because it is too large Load Diff

View File

@@ -147,8 +147,8 @@
</div>
</div>
</div>
<div class="mb-6">
<div class="flex gap-3">
<!-- Search Row -->
<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">
<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>
@@ -160,11 +160,48 @@
<option value="media">Media</option>
<option value="demo">Demo</option>
</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">
<i class="fas fa-search mr-2"></i>Search
</div>
<!-- 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>
</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 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">
@@ -177,6 +214,12 @@
</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>
<!-- Starlark Apps Section (Tronbyte Community Apps) -->
@@ -197,27 +240,74 @@
<!-- Pixlet Status Banner -->
<div id="starlark-pixlet-status" class="mb-4"></div>
<!-- Search/Filter -->
<!-- Search Row -->
<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">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 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 focus:shadow-md transition-shadow">
<option value="">All Categories</option>
</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">
<i class="fas fa-search mr-2"></i>Browse
</div>
<!-- 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>
</div>
<!-- Upload .star file -->
<div class="flex 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">
<i class="fas fa-upload mr-2"></i>Upload .star File
</button>
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<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>
<!-- 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>
<!-- 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>