diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index 108ac7f..53b7504 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -217,6 +217,60 @@ line-height: 1; } +/* Registry catalog ("marketplace") cards — apps published in the signed index + but not on this box yet. Indigo family: distinct from green (installed), + gray (not installed) and amber (improvements). */ +.app-card.registry { + border-color: rgba(99, 102, 241, 0.35); +} + +.app-tag.available-tag { + background: rgba(99, 102, 241, 0.30); + color: #a5b4fc; + border-color: rgba(99, 102, 241, 0.65); + transform: translateY(-2px); +} + +.app-tag.available-tag::before { + content: '↓'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* Official trust badge — teal check: this entry is published and signed by + the LibrePortal team key (the box verified the signature). */ +.app-tag.trust-badge { + background: rgba(34, 211, 238, 0.22); + color: #67e8f9; + border-color: rgba(34, 211, 238, 0.55); + transform: translateY(-2px); +} + +.app-tag.trust-badge::before { + content: '✓'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* The Add button — indigo to match the Available pill (install stays green). */ +.app-card-actions button[class*="add-btn"] { + background: #6366f1 !important; + color: #ffffff !important; + border: 1px solid #6366f1 !important; +} + +.app-card-actions button[class*="add-btn"]:hover { + background: #4f46e5 !important; + border-color: #4f46e5 !important; +} + +.app-card-actions button[class*="add-btn"]:disabled { + opacity: 0.65; + cursor: default; +} + /* Clickable tags (category / installed-status) — jump to that filter view */ .app-tag.clickable { cursor: pointer; diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js index 3d9bc54..3976b83 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -153,6 +153,11 @@ Object.assign(AppsManager.prototype, { const card = document.createElement('div'); card.className = 'app-card'; if (app.installed) card.classList.add('installed'); + // Registry catalog card ("Available — Add"): no local definition exists + // yet, so there is no detail page to open — the card is the whole surface + // until Add lands the definition and it becomes a normal Install card. + const isRegistry = !!app.registry; + if (isRegistry) card.classList.add('registry'); // Searchable text for the sidebar search box. Combined name + // description + long description + category, lowercased once here @@ -227,30 +232,40 @@ Object.assign(AppsManager.prototype, { } } + // Registry cards: an "Available" pill + official trust badge instead of + // the install-state pill; the top area is not clickable (no detail page). + const statusTag = isRegistry + ? `Available${app.trust === 'official' ? `Official` : ''}` + : `${status}`; + const topAttrs = isRegistry ? '' : `style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')"`; + const actionButton = isRegistry + ? `` + : ``; + card.innerHTML = ` -
+
${app.name}
-
${app.name.split(' - ')[0].trim()}
+
${app.name.split(' - ')[0].trim()}
${descriptionTag} ${categoryTag} ${instanceCountChip} - ${status} + ${statusTag}
${formattedLongDescription ? `
${formattedLongDescription}
` : ''}
- + ${actionButton} ${serviceTrigger}
`; - + return card; }, // Populate inline service trigger popups for installed apps diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index 32e4a62..58c2251 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -98,6 +98,20 @@ class AppsManager { return; } + // Registry Add — the definition landed (or the add failed); either way the + // card's state now comes from regenerated data, so reload + repaint the + // grid: on success the Available card becomes a normal Install card, on + // failure it re-renders with its Add button re-enabled. + if (action === 'app_add') { + this.clearCache(); + await this.reloadAppsData(); + const p = window.location.pathname; + if (p === '/apps' || p.startsWith('/apps/')) { + this.renderApps(window.appsCategory || 'all'); + } + return; + } + // First-install welcome modal — only on the very first successful install per app per browser. if (action === 'install' && status === 'completed' && appName) { const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`; @@ -257,6 +271,37 @@ class AppsManager { // Filter apps by category let filteredApps = appsData.apps || []; + // Registry catalog (marketplace): apps published in the signed index but + // not defined on this box render as "Available — Add" cards. The catalog + // is optional data — any failure means "no registry cards", never a + // broken grid. Local definitions always win on a slug collision. + try { + const regRes = await fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' }); + if (regRes.ok) { + const reg = await regRes.json(); + const localSlugs = new Set(filteredApps.map(a => (a.command || '').split(' ').pop())); + const registryApps = (reg.apps || []) + .filter(e => e && e.app && !e.defined && !e.installed && !localSlugs.has(e.app)) + .map(e => ({ + name: `${e.title || e.app} - ${e.description || e.why || ''}`, + description: e.description || e.why || '', + longDescription: e.long_description || '', + category: String(e.category || '').toLowerCase(), + categories: e.category ? [String(e.category).toLowerCase()] : [], + installed: false, + registry: true, + artifactId: e.id, + slug: e.app, + trust: e.trust || 'official', + publisher: e.publisher || '', + icon: e.icon || '/core/icons/apps/default.svg', + command: `libreportal app add ${e.app}`, + config: {} + })); + filteredApps = filteredApps.concat(registryApps); + } + } catch (_) { /* catalog unavailable — grid renders local apps only */ } + // Instances are managed inside their type's detail page (the family // switcher), never as standalone grid cards — hide any app that declares // INSTANCE_OF so the grid shows one card per app type. @@ -277,12 +322,9 @@ class AppsManager { }); } - // Sort installed apps first - filteredApps.sort((a, b) => { - if (a.installed && !b.installed) return -1; - if (!a.installed && b.installed) return 1; - return 0; - }); + // Sort: installed first, then local not-installed, registry cards last. + const rank = (a) => a.installed ? 0 : (a.registry ? 2 : 1); + filteredApps.sort((a, b) => rank(a) - rank(b)); // Cache the result this.cache.set(category, filteredApps); @@ -1146,6 +1188,18 @@ class AppsManager { + // Registry catalog "Add" — dispatch the app_add task that verifies the + // signed bundle and drops the definition in (mutations via tasks, as ever). + addRegistryApp(slug, btn = null) { + if (!/^[a-z0-9][a-z0-9_]{0,31}$/.test(String(slug || ''))) return; + if (!window.tasksManager?.router) { + window.notifications?.error?.('Task system not ready — try again in a moment.'); + return; + } + if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } + window.tasksManager.router.routeAction('app_add', { slug }); + } + async installApp(appName) { const installedApp = (window.apps || []).find(a => (a.command || '').endsWith(` ${appName}`) || a.name === appName diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js index 06e9d58..1fdb727 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js @@ -141,6 +141,7 @@ Object.assign(TasksManager.prototype, { 'updater_check': 'Check for Updates', 'updater_apply': 'Update', 'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back', 'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix', + 'app_add': 'Add App', 'setup-config': 'Apply Configuration', 'setup-finalize': 'Finalize Setup' }; diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js index 8ef2053..8864be0 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-actions.js +++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js @@ -358,6 +358,16 @@ async configUpdate(changes) { } catch (error) { throw new Error(`Failed to revert hotfix ${id}: ${error.message}`); } } + // Registry catalog (marketplace) — add an app definition from the signed + // index via `libreportal app add` as a task. Slug charset-guarded before it + // enters the command string (defense in depth; the CLI re-guards). + async appAdd(slug) { + if (!slug || !/^[a-z0-9][a-z0-9_]{0,31}$/.test(slug)) throw new Error('Invalid app slug'); + try { + return await this.executeTask('app_add', slug, `libreportal app add ${slug}`, `Add ${slug}`); + } catch (error) { throw new Error(`Failed to add ${slug}: ${error.message}`); } + } + /** * Create a task object */ diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js index c94ac2c..a2e44f1 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-router.js +++ b/containers/libreportal/frontend/core/tasks/js/task-router.js @@ -95,6 +95,11 @@ class TaskRouter { case 'artifact_revert': return await this.actions.artifactRevert(params.id); + // Registry catalog (marketplace) — add an app definition from the + // signed index; the App Center's "Available — Add" cards route here. + case 'app_add': + return await this.actions.appAdd(params.slug); + default: throw new Error(`Unknown action: ${action}`); }