mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-22 04:18:38 +00:00
Compare commits
2 Commits
1c4d5c5271
...
44d1a08db4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d1a08db4 | ||
|
|
6a4644007d |
18
src/cache/cache_strategy.py
vendored
18
src/cache/cache_strategy.py
vendored
@@ -194,18 +194,20 @@ class CacheStrategy:
|
|||||||
"""
|
"""
|
||||||
key_lower = key.lower()
|
key_lower = key.lower()
|
||||||
|
|
||||||
# Odds data — checked FIRST because odds keys may also contain 'live'/'current'
|
# Odds data — checked before the generic 'live' block below because
|
||||||
# (e.g. odds_espn_nba_game_123_live). The odds TTL (120s for live, 1800s for
|
# live-odds cache keys (e.g. odds_espn_basketball_nba_<id>_live) contain
|
||||||
# upcoming) must win over the generic sports_live TTL (30s) to avoid hitting
|
# both 'odds' AND 'live'. Without this ordering the 'live' check below
|
||||||
# the ESPN odds API every 30 seconds per game.
|
# would match first and return 'sports_live' (30 s TTL) instead of the
|
||||||
|
# correct 'odds_live' (120 s TTL).
|
||||||
if 'odds' in key_lower:
|
if 'odds' in key_lower:
|
||||||
# For live games, use shorter cache; for upcoming games, use longer cache
|
|
||||||
if any(x in key_lower for x in ['live', 'current']):
|
if any(x in key_lower for x in ['live', 'current']):
|
||||||
return 'odds_live' # Live odds change more frequently (120s TTL)
|
return 'odds_live' # Live odds change more frequently
|
||||||
return 'odds' # Regular odds for upcoming games (1800s TTL)
|
return 'odds' # Regular odds for upcoming games
|
||||||
|
|
||||||
# Live sports data (only reached if key does NOT contain 'odds')
|
# Live sports data
|
||||||
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
||||||
|
if 'soccer' in key_lower:
|
||||||
|
return 'sports_live' # Soccer live data is very time-sensitive
|
||||||
return 'sports_live'
|
return 'sports_live'
|
||||||
|
|
||||||
# Weather data
|
# Weather data
|
||||||
|
|||||||
@@ -1850,58 +1850,72 @@ class PluginStoreManager:
|
|||||||
return cached[1]
|
return cached[1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sha_result = subprocess.run(
|
# .git may be a file (worktree / submodule) containing "gitdir: <path>".
|
||||||
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
# Resolve it to the actual git directory before reading any files.
|
||||||
capture_output=True,
|
try:
|
||||||
text=True,
|
if git_dir.is_file():
|
||||||
timeout=10,
|
pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip()
|
||||||
check=True
|
if pointer.startswith('gitdir:'):
|
||||||
)
|
resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve()
|
||||||
sha = sha_result.stdout.strip()
|
if resolved.is_dir():
|
||||||
|
git_dir = resolved
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except (OSError, NotADirectoryError):
|
||||||
|
return None
|
||||||
|
|
||||||
branch_result = subprocess.run(
|
# Read branch directly from .git/HEAD (no subprocess).
|
||||||
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
branch = branch_result.stdout.strip()
|
|
||||||
|
|
||||||
if branch == 'HEAD':
|
|
||||||
branch = ''
|
branch = ''
|
||||||
|
try:
|
||||||
|
head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip()
|
||||||
|
if head_text.startswith('ref: refs/heads/'):
|
||||||
|
branch = head_text[len('ref: refs/heads/'):]
|
||||||
|
elif head_text.startswith('ref: '):
|
||||||
|
branch = head_text[len('ref: '):]
|
||||||
|
# else: detached HEAD — branch stays ''
|
||||||
|
except (OSError, NotADirectoryError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Get remote URL
|
# Remote URL from .git/config — parse [remote "origin"] url line.
|
||||||
remote_url_result = subprocess.run(
|
remote_url = None
|
||||||
['git', '-C', str(plugin_path), 'config', '--get', 'remote.origin.url'],
|
try:
|
||||||
capture_output=True,
|
config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace')
|
||||||
text=True,
|
in_origin = False
|
||||||
timeout=10,
|
for line in config_text.splitlines():
|
||||||
check=False
|
stripped = line.strip()
|
||||||
)
|
if stripped == '[remote "origin"]':
|
||||||
remote_url = remote_url_result.stdout.strip() if remote_url_result.returncode == 0 else None
|
in_origin = True
|
||||||
|
elif stripped.startswith('['):
|
||||||
|
in_origin = False
|
||||||
|
elif in_origin and stripped.startswith('url') and '=' in stripped:
|
||||||
|
remote_url = stripped.split('=', 1)[1].strip()
|
||||||
|
break
|
||||||
|
except (OSError, NotADirectoryError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Get commit date in ISO format
|
# Single subprocess: SHA + commit date in one call.
|
||||||
date_result = subprocess.run(
|
log_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'log', '-1', '--format=%cI', 'HEAD'],
|
['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
check=True
|
check=True
|
||||||
)
|
)
|
||||||
commit_date_iso = date_result.stdout.strip()
|
lines = log_result.stdout.strip().splitlines()
|
||||||
|
sha = lines[0] if lines else ''
|
||||||
|
commit_date_iso = lines[1] if len(lines) > 1 else ''
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'sha': sha,
|
'sha': sha,
|
||||||
'short_sha': sha[:7] if sha else '',
|
'short_sha': sha[:7] if sha else '',
|
||||||
'branch': branch
|
'branch': branch,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add remote URL if available
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
result['remote_url'] = remote_url
|
result['remote_url'] = remote_url
|
||||||
|
|
||||||
# Add commit date if available
|
|
||||||
if commit_date_iso:
|
if commit_date_iso:
|
||||||
result['date_iso'] = commit_date_iso
|
result['date_iso'] = commit_date_iso
|
||||||
result['date'] = self._iso_to_date(commit_date_iso)
|
result['date'] = self._iso_to_date(commit_date_iso)
|
||||||
|
|||||||
@@ -1772,61 +1772,57 @@ def get_installed_plugins():
|
|||||||
# Load config once before the loop (not per-plugin)
|
# Load config once before the loop (not per-plugin)
|
||||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
|
||||||
# Format for the web interface
|
def _build_plugin_entry(plugin_info):
|
||||||
plugins = []
|
|
||||||
for plugin_info in all_plugin_info:
|
|
||||||
plugin_id = plugin_info.get('id')
|
plugin_id = plugin_info.get('id')
|
||||||
|
try:
|
||||||
|
return _build_plugin_entry_inner(plugin_info, plugin_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error building plugin entry for %s — skipping", plugin_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
||||||
# Re-read manifest from disk to ensure we have the latest metadata
|
# Re-read manifest from disk to ensure we have the latest metadata
|
||||||
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||||
if manifest_path.exists():
|
if manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
fresh_manifest = json.load(f)
|
fresh_manifest = json.load(f)
|
||||||
# Update plugin_info with fresh manifest data
|
if isinstance(fresh_manifest, dict):
|
||||||
plugin_info.update(fresh_manifest)
|
plugin_info.update(fresh_manifest)
|
||||||
except Exception as e:
|
else:
|
||||||
# If we can't read the fresh manifest, use the cached one
|
logger.debug("Manifest for %s is not a dict (%s) — skipping merge",
|
||||||
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
|
plugin_id, type(fresh_manifest).__name__)
|
||||||
|
except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e:
|
||||||
|
logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e)
|
||||||
|
|
||||||
# Get enabled status from config (source of truth)
|
# Enabled status: config is source of truth, fall back to instance
|
||||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
|
||||||
enabled = None
|
enabled = None
|
||||||
if api_v3.config_manager:
|
|
||||||
plugin_config = full_config.get(plugin_id, {})
|
plugin_config = full_config.get(plugin_id, {})
|
||||||
# Check if 'enabled' key exists in config (even if False)
|
|
||||||
if 'enabled' in plugin_config:
|
if 'enabled' in plugin_config:
|
||||||
enabled = bool(plugin_config['enabled'])
|
enabled = bool(plugin_config['enabled'])
|
||||||
|
|
||||||
# Fallback to plugin instance if config doesn't have enabled key
|
# Single get_plugin() call shared for both enabled fallback and Vegas mode
|
||||||
if enabled is None:
|
|
||||||
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
if plugin_instance:
|
if enabled is None:
|
||||||
enabled = plugin_instance.enabled
|
enabled = plugin_instance.enabled if plugin_instance else True
|
||||||
else:
|
|
||||||
# Default to True if no config key and plugin not loaded (matches BasePlugin default)
|
|
||||||
enabled = True
|
|
||||||
|
|
||||||
# Get verified status from store registry (no GitHub API calls needed)
|
# Verified from registry (no network call)
|
||||||
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||||
verified = store_info.get('verified', False) if store_info else False
|
verified = store_info.get('verified', False) if store_info else False
|
||||||
|
|
||||||
# Get local git info for installed plugin (actual installed commit)
|
# Local git info (single subprocess on cache miss, zero on hit)
|
||||||
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
||||||
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None
|
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None
|
||||||
|
|
||||||
# Use local git info if available (actual installed commit), otherwise fall back to manifest/store info
|
|
||||||
if local_git_info:
|
if local_git_info:
|
||||||
last_commit = local_git_info.get('short_sha') or local_git_info.get('sha', '')[:7] if local_git_info.get('sha') else None
|
sha = local_git_info.get('sha', '')
|
||||||
|
last_commit = local_git_info.get('short_sha') or (sha[:7] if sha else None)
|
||||||
branch = local_git_info.get('branch')
|
branch = local_git_info.get('branch')
|
||||||
# Use commit date from git if available
|
|
||||||
last_updated = local_git_info.get('date_iso') or local_git_info.get('date')
|
last_updated = local_git_info.get('date_iso') or local_git_info.get('date')
|
||||||
else:
|
else:
|
||||||
# Fall back to manifest/store info if no local git info
|
|
||||||
last_updated = plugin_info.get('last_updated')
|
last_updated = plugin_info.get('last_updated')
|
||||||
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
||||||
branch = plugin_info.get('branch')
|
branch = plugin_info.get('branch')
|
||||||
|
|
||||||
if store_info:
|
if store_info:
|
||||||
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
||||||
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
||||||
@@ -1836,35 +1832,26 @@ def get_installed_plugins():
|
|||||||
if store_info and not last_commit_message:
|
if store_info and not last_commit_message:
|
||||||
last_commit_message = store_info.get('last_commit_message')
|
last_commit_message = store_info.get('last_commit_message')
|
||||||
|
|
||||||
# Get web_ui_actions from manifest if available
|
# Vegas mode from instance, overridden by explicit config value
|
||||||
web_ui_actions = plugin_info.get('web_ui_actions', [])
|
|
||||||
|
|
||||||
# Get Vegas display mode info from plugin instance
|
|
||||||
vegas_mode = None
|
vegas_mode = None
|
||||||
vegas_content_type = None
|
vegas_content_type = None
|
||||||
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
||||||
if plugin_instance:
|
if plugin_instance:
|
||||||
try:
|
try:
|
||||||
# Try to get the display mode enum
|
|
||||||
if hasattr(plugin_instance, 'get_vegas_display_mode'):
|
if hasattr(plugin_instance, 'get_vegas_display_mode'):
|
||||||
mode = plugin_instance.get_vegas_display_mode()
|
mode = plugin_instance.get_vegas_display_mode()
|
||||||
vegas_mode = mode.value if hasattr(mode, 'value') else str(mode)
|
vegas_mode = mode.value if hasattr(mode, 'value') else str(mode)
|
||||||
except (AttributeError, TypeError, ValueError) as e:
|
except (AttributeError, TypeError, ValueError) as e:
|
||||||
logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e)
|
logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e)
|
||||||
try:
|
try:
|
||||||
# Get legacy content type as fallback
|
|
||||||
if hasattr(plugin_instance, 'get_vegas_content_type'):
|
if hasattr(plugin_instance, 'get_vegas_content_type'):
|
||||||
vegas_content_type = plugin_instance.get_vegas_content_type()
|
vegas_content_type = plugin_instance.get_vegas_content_type()
|
||||||
except (AttributeError, TypeError, ValueError) as e:
|
except (AttributeError, TypeError, ValueError) as e:
|
||||||
logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e)
|
logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e)
|
||||||
|
|
||||||
# Also check plugin config for explicit vegas_mode setting
|
if 'vegas_mode' in plugin_config:
|
||||||
if api_v3.config_manager:
|
vegas_mode = plugin_config['vegas_mode']
|
||||||
plugin_cfg = full_config.get(plugin_id, {})
|
|
||||||
if 'vegas_mode' in plugin_cfg:
|
|
||||||
vegas_mode = plugin_cfg['vegas_mode']
|
|
||||||
|
|
||||||
plugins.append({
|
return {
|
||||||
'id': plugin_id,
|
'id': plugin_id,
|
||||||
'name': plugin_info.get('name', plugin_id),
|
'name': plugin_info.get('name', plugin_id),
|
||||||
'version': plugin_info.get('version', ''),
|
'version': plugin_info.get('version', ''),
|
||||||
@@ -1879,10 +1866,15 @@ def get_installed_plugins():
|
|||||||
'last_commit': last_commit,
|
'last_commit': last_commit,
|
||||||
'last_commit_message': last_commit_message,
|
'last_commit_message': last_commit_message,
|
||||||
'branch': branch,
|
'branch': branch,
|
||||||
'web_ui_actions': web_ui_actions,
|
'web_ui_actions': plugin_info.get('web_ui_actions', []),
|
||||||
'vegas_mode': vegas_mode,
|
'vegas_mode': vegas_mode,
|
||||||
'vegas_content_type': vegas_content_type
|
'vegas_content_type': vegas_content_type,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
|
results = list(executor.map(_build_plugin_entry, all_plugin_info))
|
||||||
|
plugins = [r for r in results if r is not None]
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1223,12 +1223,20 @@ function initializePlugins() {
|
|||||||
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
||||||
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
||||||
window.pluginManager._reswap = false;
|
window.pluginManager._reswap = false;
|
||||||
// Await the installed-plugins fetch so window.installedPlugins is populated before
|
|
||||||
// searchPluginStore renders Installed/Reinstall badges against it.
|
// Fire both requests in parallel so the store doesn't wait for installed plugins.
|
||||||
loadInstalledPlugins().catch(err => {
|
// The store renders install/update badges using window.installedPlugins || [] so
|
||||||
console.error('[PluginStore] loadInstalledPlugins failed:', err);
|
// it works with an empty list. When installed plugins finish loading we do a
|
||||||
}).finally(() => {
|
// lightweight re-render from the already-cached store data to refresh the badges.
|
||||||
searchPluginStore(!isReswapWarm);
|
searchPluginStore(!isReswapWarm);
|
||||||
|
loadInstalledPlugins()
|
||||||
|
.catch(err => console.error('[PluginStore] loadInstalledPlugins failed:', err))
|
||||||
|
.then(() => {
|
||||||
|
// Re-render store from cache to update install/update/reinstall badges now
|
||||||
|
// that window.installedPlugins is populated. No network call — instant.
|
||||||
|
if (typeof applyStoreFiltersAndSort === 'function') {
|
||||||
|
applyStoreFiltersAndSort(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup search functionality (with guard against duplicate listeners)
|
// Setup search functionality (with guard against duplicate listeners)
|
||||||
@@ -1418,6 +1426,9 @@ function renderInstalledPlugins(plugins) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove skeleton cards before rendering real content
|
||||||
|
container.querySelectorAll('.installed-skeleton').forEach(el => el.remove());
|
||||||
|
|
||||||
if (plugins.length === 0) {
|
if (plugins.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="col-span-full empty-state">
|
<div class="col-span-full empty-state">
|
||||||
|
|||||||
@@ -380,8 +380,8 @@
|
|||||||
<!-- Plugin order list will be populated by JavaScript -->
|
<!-- Plugin order list will be populated by JavaScript -->
|
||||||
<p class="text-sm text-gray-500 italic">Loading plugins...</p>
|
<p class="text-sm text-gray-500 italic">Loading plugins...</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="vegas_plugin_order_value" name="vegas_plugin_order" value="{{ main_config.display.get('vegas_scroll', {}).get('plugin_order', [])|tojson }}">
|
<input type="hidden" id="vegas_plugin_order_value" name="vegas_plugin_order" value='{{ main_config.display.get("vegas_scroll", {}).get("plugin_order", [])|tojson }}'>
|
||||||
<input type="hidden" id="vegas_excluded_plugins_value" name="vegas_excluded_plugins" value="{{ main_config.display.get('vegas_scroll', {}).get('excluded_plugins', [])|tojson }}">
|
<input type="hidden" id="vegas_excluded_plugins_value" name="vegas_excluded_plugins" value='{{ main_config.display.get("vegas_scroll", {}).get("excluded_plugins", [])|tojson }}'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,52 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="installed-plugins-content" class="block">
|
<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">
|
<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">
|
||||||
<!-- Plugins will be loaded here -->
|
<!-- Skeleton cards shown while installed plugins load -->
|
||||||
|
<div class="installed-skeleton plugin-card animate-pulse">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="installed-skeleton plugin-card animate-pulse hidden md:block">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-4/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="installed-skeleton plugin-card animate-pulse hidden lg:block">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-4/5"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-2/5"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-3/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,14 +248,30 @@
|
|||||||
</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 — hidden by showStoreLoading(false) when data arrives -->
|
||||||
<div class="store-loading col-span-full">
|
<div class="store-loading col-span-full">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||||
<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
|
{% for _ in range(10) %}
|
||||||
<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
|
<div class="plugin-card animate-pulse">
|
||||||
<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-5 w-14 bg-gray-200 rounded-full ml-3 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-4/6"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-auto">
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-full mt-3"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user