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/html/apps-unified-layout.html b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html index cb4158f..bcdd307 100755 --- a/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html +++ b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html @@ -19,20 +19,6 @@ Overview - -
- - Marketplace -
- -
-
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 5619055..3976b83 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -35,35 +35,6 @@ Object.assign(AppsManager.prototype, { // Set active category this.setActiveCategory(activeCategory); - - // Refresh the Marketplace sidebar badge (apps available to add). Best-effort - // and cached — the sidebar persists across apps sub-routes. - this.updateMarketplaceBadge(); - }, - // Show "N" on the Marketplace sidebar entry = catalog apps not yet on this box. - // No badge when the catalog is absent or nothing is addable. - updateMarketplaceBadge() { - const entry = document.getElementById('sidebar-marketplace-entry'); - if (!entry) return; - const paint = (n) => { - let badge = entry.querySelector('.mkt-entry-badge'); - if (!n) { if (badge) badge.remove(); return; } - if (!badge) { - badge = document.createElement('span'); - badge.className = 'mkt-entry-badge'; - entry.appendChild(badge); - } - badge.textContent = String(n); - }; - if (this._mktBadgeCount != null) { paint(this._mktBadgeCount); return; } - fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' }) - .then((r) => (r.ok ? r.json() : null)) - .then((c) => { - const n = (c && Array.isArray(c.apps)) ? c.apps.filter((a) => !a.defined && !a.installed).length : 0; - this._mktBadgeCount = n; - paint(n); - }) - .catch(() => {}); }, addCategory(name, id, icon) { const container = document.getElementById('dynamic-categories'); @@ -182,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 @@ -256,26 +232,36 @@ 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}
`; 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 ce8cd04..8e9809b 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -100,16 +100,15 @@ class AppsManager { // 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: the added app becomes a normal not-installed grid card. Only repaint - // the actual grid (not the Marketplace / Overview sub-routes, which own their - // own refresh) so we never fight another pane's render. + // 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(); - this._mktBadgeCount = null; // catalog available-count changed — re-fetch await this.reloadAppsData(); const p = window.location.pathname; - const onGrid = p === '/apps' || (p.startsWith('/apps/') && !p.startsWith('/apps/marketplace') && !p.startsWith('/apps/overview')); - if (onGrid) this.renderApps(window.appsCategory || 'all'); + if (p === '/apps' || p.startsWith('/apps/')) { + this.renderApps(window.appsCategory || 'all'); + } return; } @@ -269,12 +268,40 @@ class AppsManager { const appsData = await response.json(); - // Filter apps by category. The grid is "your apps" — definitions on this - // box (installed or ready to install). The remote signed catalog - // (browse-and-add) has its own home in the Marketplace section, so it is - // NOT merged here. + // 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. @@ -308,8 +335,9 @@ class AppsManager { }); } - // Installed apps first. - filteredApps.sort((a, b) => (a.installed ? 0 : 1) - (b.installed ? 0 : 1)); + // 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); @@ -510,7 +538,6 @@ class AppsManager { 'apps': document.getElementById('apps-view'), 'app-detail': document.getElementById('app-detail-view'), 'overview': document.getElementById('overview-view'), - 'marketplace': document.getElementById('marketplace-view'), }; Object.keys(views).forEach((key) => { const el = views[key]; @@ -1174,6 +1201,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/apps/index.js b/containers/libreportal/frontend/components/apps/index.js index a1263e9..0d05cfd 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -17,42 +17,12 @@ LP.features.register({ if (window.location.pathname.startsWith('/apps/overview')) { return this._mountOverview(ctx); } - if (window.location.pathname.startsWith('/apps/marketplace')) { - return this._mountMarketplace(ctx); - } if (window.location.pathname.startsWith('/apps')) { return this._mountGrid(ctx); } return this._mountDetail(ctx); }, - // ---- Marketplace (/apps/marketplace) ---- - async _mountMarketplace(ctx) { - if (typeof MarketplacePage === 'undefined') { - await ctx.loadScripts(['/components/apps/marketplace/js/marketplace-page.js']); - } - // Reuse the shared apps layout (keeps the sidebar persistent), same as - // grid/overview. The Marketplace pane + sidebar entry live in the fragment. - if (!document.querySelector('.apps-layout')) { - const html = await ctx.loadFragment('/components/apps/core/html/apps-unified-layout.html'); - ctx.setContent(html, 'Marketplace'); - } - // Render the apps sidebar (search + categories) but show the Marketplace - // pane and highlight the Marketplace entry instead of any category. - if (window.appsManager) { - window.appsManager.currentView = 'marketplace'; - try { window.appsManager.setupSidebar('__marketplace__'); } catch (_) {} - window.appsManager.showView('marketplace'); - } - document.querySelectorAll('.apps-layout .category.active').forEach((c) => c.classList.remove('active')); - const entry = document.getElementById('sidebar-marketplace-entry'); - if (entry) entry.classList.add('active'); - - if (typeof MarketplacePage === 'undefined') throw new Error('MarketplacePage failed to load'); - if (!window.marketplacePage) window.marketplacePage = new MarketplacePage(ctx.services); - await window.marketplacePage.initialize(); - }, - // ---- grid (/apps, /apps/) ---- async _mountGrid(ctx) { const seg = window.location.pathname.replace(/^\/apps\/?/, '').split('/')[0]; @@ -156,8 +126,6 @@ LP.features.register({ // overviewManager singleton + its DOM persist with the layout; its run() // self-guards, but unregistering is the clean release. try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {} - // Same for the Marketplace page's task-refresh registration. - try { window.marketplacePage && window.marketplacePage.dispose && window.marketplacePage.dispose(); } catch (_) {} // Same for the headless updater's auto-refresh timer (re-armed on the next // Overview mount). try { window.overviewManager && window.overviewManager.updater && window.overviewManager.updater.dispose(); } catch (_) {} diff --git a/containers/libreportal/frontend/components/apps/marketplace/css/marketplace.css b/containers/libreportal/frontend/components/apps/marketplace/css/marketplace.css deleted file mode 100644 index 4239e67..0000000 --- a/containers/libreportal/frontend/components/apps/marketplace/css/marketplace.css +++ /dev/null @@ -1,127 +0,0 @@ -/* Marketplace section — the signed-catalog browse-and-add destination inside the - App Center. Namespaced .mkt-* to avoid cascade collisions with the grid. - Indigo/teal identity (distinct from the grid's green install / amber - improvements), nebula-theme tokens throughout, no third-party assets. */ - -:root { - --mkt-indigo: #6366f1; - --mkt-indigo-rgb: 99, 102, 241; - --mkt-teal: #22d3ee; - --mkt-teal-rgb: 34, 211, 238; -} - -.mkt { padding: 4px 2px 32px; } - -/* Hero — reuses the .config-title shape (title + desc left, actions right). */ -.mkt-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.mkt-hero-main h3 { margin: 0 0 4px; } -.mkt-hero-actions { flex-shrink: 0; } - -/* Status strip — the marketplace's "identity + provenance" line. */ -.mkt-statusbar { - display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; - margin: 4px 2px 20px; padding: 10px 14px; - background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 10px; - font-size: 0.85rem; -} -.mkt-stat { color: var(--text-secondary); white-space: nowrap; } -.mkt-stat.mkt-dim { opacity: 0.7; } -.mkt-stat.mkt-ok { color: #67e8f9; font-weight: 600; } -.mkt-stat.mkt-warn { color: #fcd34d; font-weight: 600; } -.mkt-stat.mkt-stat-none { color: var(--text-secondary); font-style: italic; } - -/* Toolbar — search + category chips. */ -.mkt-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 18px; } -.mkt-search-wrap { flex: 1; min-width: 220px; } -.mkt-search { - width: 100%; padding: 9px 14px; border-radius: 10px; - border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-primary); - font-size: 0.92rem; outline: none; -} -.mkt-search:focus { border-color: var(--mkt-indigo); } -.mkt-chips { display: flex; flex-wrap: wrap; gap: 8px; } -.mkt-chip { - padding: 5px 13px; border-radius: 999px; font-size: 0.82rem; cursor: pointer; - border: 1px solid var(--border-color); background: var(--card-bg); color: var(--text-secondary); - transition: all 0.15s ease; text-transform: capitalize; -} -.mkt-chip:hover { border-color: rgba(var(--mkt-indigo-rgb), 0.6); } -.mkt-chip.active { - background: rgba(var(--mkt-indigo-rgb), 0.28); border-color: var(--mkt-indigo); color: #c7d2fe; -} - -/* Shelf headings. */ -.mkt-shelf-title { font-size: 0.82rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin: 8px 2px 12px; } -.mkt-all-title { margin-top: 24px; } -.mkt-featured .mkt-shelf-title { color: #a5b4fc; } - -/* Grid. */ -.mkt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } -.mkt-grid-featured { margin-bottom: 8px; } - -.mkt-card { - background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 14px; - padding: 16px; display: flex; flex-direction: column; gap: 10px; cursor: pointer; - transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; -} -.mkt-card:hover { border-color: rgba(var(--mkt-indigo-rgb), 0.55); transform: translateY(-2px); box-shadow: var(--card-shadow-hover, 0 8px 24px rgba(0,0,0,0.25)); } -.mkt-card:focus-visible { outline: 2px solid var(--mkt-indigo); outline-offset: 2px; } -.mkt-card-featured { border-color: rgba(var(--mkt-indigo-rgb), 0.4); background: linear-gradient(180deg, rgba(var(--mkt-indigo-rgb), 0.08), var(--card-bg) 60%); } - -.mkt-card-top { display: flex; gap: 12px; align-items: center; } -.mkt-card-icon { - width: 46px; height: 46px; border-radius: 11px; flex-shrink: 0; overflow: hidden; - background: rgba(var(--text-rgb), 0.08); display: flex; align-items: center; justify-content: center; -} -.mkt-card-icon img { width: 100%; height: 100%; object-fit: contain; } -.mkt-card-head { min-width: 0; } -.mkt-card-title { font-weight: 600; font-size: 1.02rem; color: var(--text-primary); } -.mkt-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } - -.mkt-tag { font-size: 0.72rem; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--text-secondary); text-transform: capitalize; } -.mkt-tag-avail { background: rgba(var(--mkt-indigo-rgb), 0.22); border-color: rgba(var(--mkt-indigo-rgb), 0.5); color: #a5b4fc; } -.mkt-tag-added { background: rgba(var(--status-warning-rgb, 245,158,11), 0.2); border-color: rgba(var(--status-warning-rgb, 245,158,11), 0.5); color: #fcd34d; } -.mkt-tag-installed { background: rgba(var(--status-success-rgb), 0.28); border-color: rgba(var(--status-success-rgb), 0.6); color: #86efac; } - -.mkt-badge { font-size: 0.72rem; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border-color); } -.mkt-badge-official { background: rgba(var(--mkt-teal-rgb), 0.2); border-color: rgba(var(--mkt-teal-rgb), 0.5); color: #a5f3fc; } -.mkt-badge-official::before { content: '✓ '; } -.mkt-badge-community { background: rgba(var(--status-warning-rgb, 245,158,11), 0.2); border-color: rgba(var(--status-warning-rgb, 245,158,11), 0.5); color: #fcd34d; } - -.mkt-card-desc { color: var(--text-secondary); font-size: 0.86rem; line-height: 1.45; flex: 1; - display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } -.mkt-card-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 2px; } -.mkt-card-ver { font-size: 0.78rem; color: var(--text-secondary); opacity: 0.75; } - -.mkt-btn { - padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; - font-weight: 600; font-size: 0.85rem; transition: background 0.15s ease; -} -.mkt-btn-add { background: var(--mkt-indigo); color: #fff; } -.mkt-btn-add:hover { background: #4f46e5; } -.mkt-btn-setup { background: rgba(var(--status-warning-rgb, 245,158,11), 0.9); color: #1a1206; } -.mkt-btn-setup:hover { filter: brightness(1.08); } -.mkt-btn-ghost { background: transparent; border: 1px solid var(--border-color); color: var(--text-secondary); } -.mkt-btn-ghost:disabled { opacity: 0.5; cursor: default; } - -/* Empty / no-data. */ -.mkt-empty { - text-align: center; padding: 40px 20px; color: var(--text-secondary); - background: var(--card-bg); border: 1px dashed var(--border-color); border-radius: 12px; line-height: 1.6; -} -.mkt-empty code { background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 6px; } - -/* Detail-modal bits. */ -.mkt-modal-desc { color: var(--text-secondary); line-height: 1.55; margin: 0; } -.mkt-cmd { display: block; font-size: 0.82rem; padding: 8px 10px; border-radius: 8px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); overflow-x: auto; white-space: nowrap; } - -/* Sidebar entry — pinned, mirrors the Overview entry with an indigo accent when - active. Count badge sits at the trailing edge. */ -.sidebar-marketplace-entry { display: flex; align-items: center; gap: 10px; } -.sidebar-marketplace-entry .mkt-entry-icon { width: 20px; height: 20px; flex-shrink: 0; } -.sidebar-marketplace-entry .mkt-entry-badge { - margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; - background: rgba(var(--mkt-indigo-rgb), 0.85); color: #fff; font-size: 0.72rem; font-weight: 700; - display: inline-flex; align-items: center; justify-content: center; -} -.sidebar-marketplace-entry.active .mkt-entry-badge { background: #fff; color: var(--mkt-indigo); } diff --git a/containers/libreportal/frontend/components/apps/marketplace/js/marketplace-page.js b/containers/libreportal/frontend/components/apps/marketplace/js/marketplace-page.js deleted file mode 100644 index 64c1bc9..0000000 --- a/containers/libreportal/frontend/components/apps/marketplace/js/marketplace-page.js +++ /dev/null @@ -1,390 +0,0 @@ -// components/apps/marketplace/js/marketplace-page.js — the Marketplace. -// -// A dedicated destination inside the App Center for the SIGNED REGISTRY CATALOG -// (apps published to the channel, fetched + verified host-side, surfaced here as -// browse-and-add). It lives in the apps feature so it shares the persistent apps -// sidebar — the "Marketplace" sidebar entry renders this in the main pane, the -// same way "Overview" renders the fleet Overview. -// -// The split mirrors WordPress: the App Center GRID is "your apps" (definitions -// on this box — installed or ready to install); the MARKETPLACE is "get more -// apps" (the remote catalog). Data source is the same generated JSON the grid -// used to merge — /data/apps/generated/registry_catalog.json, produced by -// webui_registry_scan.sh — but here it gets a status strip, a curated Featured -// row, per-app detail, and the chained Add-&-set-up flow. -// -// Adding is mutations-via-tasks: the card/modal buttons route the app_add task -// (libreportal app add ) through the shared task router; when it lands the -// definition, the box has a normal not-installed app and the chain hands off to -// its config/install page. - -class MarketplacePage { - constructor(services) { - this.services = services || (window.LP && window.LP.services) || {}; - this.catalog = null; // parsed registry_catalog.json - this.cat = 'all'; // active category chip - this.q = ''; // search query - this._pendingSetup = null; // slug awaiting its app_add to finish, then → /app/ - this._boundView = null; - } - - // ---- lifecycle ----------------------------------------------------------- - - async initialize() { - const root = document.getElementById('marketplace-view'); - if (!root) return; - this.bindEvents(root); - await this.refreshAll(); - this.render(); - } - - bindEvents(root) { - // One delegated listener per fresh root (the apps layout fragment persists - // across /apps,/app,/apps/overview,/apps/marketplace, so the same root is - // reused — the dataset guard stops a second listener stacking on revisit). - if (root.dataset.mktBound !== '1') { - root.dataset.mktBound = '1'; - root.addEventListener('click', (e) => this._handleClick(e)); - root.addEventListener('input', (e) => { - if (e.target && e.target.id === 'mkt-search') { this.q = e.target.value || ''; this.renderBody(); } - }); - root.addEventListener('keydown', (e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - const t = e.target.closest('[data-mkt-action][role="button"], .mkt-card[role="button"]'); - if (!t || !root.contains(t)) return; - e.preventDefault(); - this._handleClick(e); - }); - } - // Repaint when an add / registry re-scan finishes — debounced, idempotent by - // id, self-guarded against a torn-down view. Re-armed on every initialize() - // after the apps unmount unregistered it. - const tr = this.services.tasks && this.services.tasks.refresh; - if (tr && tr.register) { - tr.register({ - id: 'marketplace', - match: (d) => /^(app_add|updater_)|libreportal\s+(app\s+add|updater\s+check)/ - .test((d && (d.action || (d.task && d.task.command))) || ''), - run: () => { - if (window.marketplacePage !== this || !document.getElementById('marketplace-view')) return; - return this.refreshAll().then(() => { - this.render(); - this._continuePendingSetup(); - }); - }, - debounceMs: 600, - }); - } - } - - dispose() { - try { this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.unregister('marketplace'); } catch (_) {} - } - - async refreshAll() { - try { - const r = await fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' }); - this.catalog = r.ok ? await r.json() : null; - } catch (_) { this.catalog = null; } - } - - // ---- data helpers -------------------------------------------------------- - - apps() { return (this.catalog && Array.isArray(this.catalog.apps)) ? this.catalog.apps : []; } - - categories() { - const set = new Set(); - this.apps().forEach((a) => { if (a.category) set.add(String(a.category).toLowerCase()); }); - return Array.from(set).sort(); - } - - filtered() { - const q = this.q.trim().toLowerCase(); - return this.apps().filter((a) => { - if (this.cat !== 'all' && String(a.category || '').toLowerCase() !== this.cat) return false; - if (q) { - const hay = `${a.title} ${a.description} ${a.long_description} ${a.app}`.toLowerCase(); - if (hay.indexOf(q) < 0) return false; - } - return true; - }); - } - - // available = not on the box yet; the count the sidebar badge + hero report. - availableCount() { return this.apps().filter((a) => !a.defined && !a.installed).length; } - - state(a) { - if (a.installed) return 'installed'; - if (a.defined) return 'added'; // definition present, not deployed yet - return 'available'; - } - - // ---- render -------------------------------------------------------------- - - render() { - const root = document.getElementById('marketplace-view'); - if (!root) return; - const c = this.catalog; - const avail = this.availableCount(); - const total = this.apps().length; - - // Status strip: what the catalog is + where it came from + freshness, with a - // Refresh that re-runs the host-side registry scan (via `updater check`). - let status; - if (!c) { - status = `No catalog yet — the box hasn't fetched a registry, or none is published.`; - } else { - const src = (c.source && c.source.base) ? `${this.escape(c.source.base)} · ${this.escape(c.source.channel || 'stable')}` : 'default channel'; - const sig = c.signed - ? `✓ signed` - : `unsigned`; - const when = c.generated_at ? this.fmtRel(c.generated_at) : '—'; - status = `${sig} - ${avail} available - ${total} in catalog - serial ${this.escape(String(c.serial != null ? c.serial : '?'))} - source ${src} - refreshed ${this.escape(when)}`; - } - - root.innerHTML = ` -
-
-
-

🛒 Marketplace

-

Browse the signed app catalog and add apps to your box. Adding fetches the app's definition, verifies its signature, and drops it in — then it installs like any other app.

-
-
- -
-
-
${status}
-
-
`; - - this.renderBody(); - } - - renderBody() { - const host = document.getElementById('mkt-body'); - if (!host) return; - if (!this.catalog) { - host.innerHTML = this.empty('Nothing to browse yet. Point CFG_RELEASE_BASE_URL at a marketplace and run a refresh, or wait for the next automatic scan.'); - return; - } - if (!this.apps().length) { - host.innerHTML = this.empty('The catalog is reachable but has no apps published on this channel yet.'); - return; - } - - const cats = this.categories(); - const chip = (id, label) => - ``; - const chips = [chip('all', 'All')].concat(cats.map((c) => chip(c, c))).join(''); - - const shown = this.filtered(); - - // Featured shelf — publisher-curated (meta.featured, set at publish time; no - // tracking/popularity). Only when unfiltered, and only its still-addable apps. - let featuredHtml = ''; - if (this.cat === 'all' && !this.q.trim()) { - const feat = this.apps().filter((a) => a.featured && this.state(a) !== 'installed'); - if (feat.length) { - featuredHtml = ` - `; - } - } - - const grid = shown.length - ? `
${shown.map((a) => this.card(a, false)).join('')}
` - : this.empty('Nothing matches your search.'); - - host.innerHTML = ` -
-
- -
-
${chips}
-
- ${featuredHtml} -
${this.cat === 'all' ? 'All apps' : this.titleCase(this.cat)}
- ${grid}`; - } - - card(a, featured) { - const st = this.state(a); - const icon = this.escapeAttr(a.icon || '/core/icons/apps/default.svg'); - const trust = a.trust === 'official' - ? `Official` - : `${this.escape(a.trust || 'community')}`; - let action; - if (st === 'installed') { - action = ``; - } else if (st === 'added') { - action = ``; - } else if (this.catalog && this.catalog.signed) { - action = ``; - } else { - action = ``; - } - const stateTag = st === 'installed' - ? `Installed` - : (st === 'added' ? `Added` : `Available`); - return ` -
-
-
-
-
${this.escape(a.title || a.app)}
-
- ${this.escape(a.category || 'app')} - ${trust} - ${stateTag} -
-
-
-
${this.escape(a.long_description || a.description || '')}
-
- v${this.escape(String(a.version != null ? a.version : 1))} - ${action} -
-
`; - } - - // ---- detail modal -------------------------------------------------------- - - openDetails(slug) { - const a = this.apps().find((x) => x.app === slug); - if (!a) return; - const st = this.state(a); - const trustVariant = a.trust === 'official' ? 'success' : 'warning'; - const badges = window.eoBadgeRow ? window.eoBadgeRow([ - { icon: '🏷️', label: a.category || 'app', variant: 'info' }, - { icon: a.trust === 'official' ? '✓' : '•', label: (a.trust === 'official' ? 'Official — signed by ' : '') + (a.publisher || 'community'), variant: trustVariant }, - { icon: '⑆', label: `v${a.version != null ? a.version : 1}`, variant: 'purple' }, - ]) : ''; - const body = [ - badges, - window.eoSection ? window.eoSection('About', `

${this.escape(a.long_description || a.description || 'No description provided.')}

`) : `

${this.escape(a.long_description || a.description || '')}

`, - window.eoSection ? window.eoSection('Add command', `libreportal app add ${this.escape(a.app)}`) : '', - ]; - - const actions = []; - if (st === 'installed') { - actions.push({ label: 'Open app', variant: 'primary', onClick: (m) => { m.close(); this._goApp(a.app); } }); - } else if (st === 'added') { - actions.push({ label: 'Set up →', variant: 'primary', onClick: (m) => { m.close(); this._goApp(a.app); } }); - } else if (this.catalog && this.catalog.signed) { - actions.push({ label: 'Add & set up', variant: 'primary', onClick: (m) => { m.close(); this._addAndSetup(a.app); } }); - actions.push({ label: 'Add only', variant: 'secondary', onClick: (m) => { m.close(); this._add(a.app); } }); - } else { - actions.push({ label: 'Adding needs a signed catalog', variant: 'secondary', onClick: (m) => m.close() }); - } - actions.push({ label: 'Close', variant: 'ghost', onClick: (m) => m.close() }); - - window.openEoModal({ - id: 'mkt-detail-modal', - size: 'md', - icon: a.icon || '/core/icons/apps/default.svg', - eyebrow: a.category || 'Marketplace', - title: a.title || a.app, - desc: a.description || '', - body, - actions, - }); - } - - // ---- actions ------------------------------------------------------------- - - _dispatchAdd(slug) { - const route = this.services.tasks && this.services.tasks.route; - if (!route || !route.routeAction) { - this.toast('Task system not ready — try again in a moment.', 'error'); - return false; - } - route.routeAction('app_add', { slug }); - return true; - } - - _add(slug) { - if (this._dispatchAdd(slug)) this.toast(`Adding ${slug}…`, 'info'); - } - - // The chained WordPress-style flow: add the definition, then when app_add - // finishes land on the app's config/install page. _continuePendingSetup() - // (called from the task-refresh run) performs the hand-off once the app is - // actually defined, so we never navigate to a page that isn't there yet. - _addAndSetup(slug) { - if (this._dispatchAdd(slug)) { - this._pendingSetup = slug; - this.toast(`Adding ${slug} — you'll be taken to set it up.`, 'info'); - } - } - - _continuePendingSetup() { - const slug = this._pendingSetup; - if (!slug) return; - const a = this.apps().find((x) => x.app === slug); - if (a && (a.defined || a.installed)) { - this._pendingSetup = null; - this._goApp(slug); - } - } - - _goApp(slug) { - if (typeof window.navigateToRoute === 'function') window.navigateToRoute(`/app/${encodeURIComponent(slug)}`); - else window.location.href = `/app/${encodeURIComponent(slug)}`; - } - - _handleClick(e) { - const el = e.target.closest('[data-mkt-action]'); - if (!el) return; - const slug = el.dataset.slug; - switch (el.dataset.mktAction) { - case 'refresh': - this.toast('Refreshing the catalog…', 'info'); - { const route = this.services.tasks && this.services.tasks.route; if (route && route.routeAction) route.routeAction('updater_check', {}); } - break; - case 'chip': - this.cat = el.dataset.cat || 'all'; - this.renderBody(); - break; - case 'add': e.stopPropagation(); this._add(slug); break; - case 'setup': e.stopPropagation(); this._goApp(slug); break; - case 'open': e.stopPropagation(); this._goApp(slug); break; - case 'details': this.openDetails(slug); break; - } - } - - // ---- utils --------------------------------------------------------------- - - toast(msg, kind) { - try { if (window.notifications && window.notifications[kind || 'info']) return window.notifications[kind || 'info'](msg); } catch (_) {} - try { const n = this.services && this.services.notify; if (n && n[kind || 'info']) return n[kind || 'info'](msg); } catch (_) {} - } - - empty(html) { return `
${html}
`; } - - titleCase(s) { s = String(s || ''); return s.charAt(0).toUpperCase() + s.slice(1); } - - fmtRel(iso) { - const t = Date.parse(iso); - if (isNaN(t)) return String(iso); - const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); - if (s < 60) return 'just now'; - if (s < 3600) return `${Math.floor(s / 60)}m ago`; - if (s < 86400) return `${Math.floor(s / 3600)}h ago`; - return `${Math.floor(s / 86400)}d ago`; - } - - escape(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - } - - escapeAttr(s) { return this.escape(s); } -} - -window.MarketplacePage = MarketplacePage; diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index 9525bb2..4230f46 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -32,7 +32,6 @@ - diff --git a/scripts/release/make_app.sh b/scripts/release/make_app.sh index a543fae..0d86194 100644 --- a/scripts/release/make_app.sh +++ b/scripts/release/make_app.sh @@ -21,13 +21,10 @@ # channel stable (default) | edge # spec.json optional overlay for envelope fields the .config can't provide: # { "id", "version", "why", "severity", "supersedes": [], -# "publisher", "trust", "featured": true, -# "applies_when": {"min_lp","max_lp","max_footprint"} } +# "publisher", "trust", "applies_when": {"min_lp","max_lp","max_footprint"} } # Defaults: id=app-, version=auto-bump, severity=tweak, -# publisher=libreportal, trust=official, featured=false (the -# curated Marketplace shelf — set at publish time, no tracking). -# `auto` is ALWAYS false — apps are never auto-applied; the box -# enforces the same rule. +# publisher=libreportal, trust=official. `auto` is ALWAYS false — +# apps are never auto-applied; the box enforces the same rule. # # Catalog metadata (title/category/description/long description) comes from the # app's own .config — one source of truth with the App Center generators. @@ -150,15 +147,12 @@ for ext in svg png; do fi done -FEATURED="$(specget '.featured')" META="$(jq -cn \ --arg category "$CATEGORY" --arg description "$DESCRIPTION" \ --arg long_description "$LONG_DESCRIPTION" \ - --arg icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" \ - --arg featured "$FEATURED" ' + --arg icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" ' {category:$category, description:$description, long_description:$long_description} - + (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end) - + (if $featured == "true" then {featured:true} else {} end)')" + + (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end)')" # --- build the envelope (§8.1 shape; type:"app", payload.kind:"bundle") ------- envelope="$(jq -cn \ diff --git a/scripts/webui/data/generators/apps/webui_registry_scan.sh b/scripts/webui/data/generators/apps/webui_registry_scan.sh index 55b5e9f..ca2c7c0 100644 --- a/scripts/webui/data/generators/apps/webui_registry_scan.sh +++ b/scripts/webui/data/generators/apps/webui_registry_scan.sh @@ -92,12 +92,10 @@ webuiRegistryCatalogScan() { local tmp; tmp="$(mktemp)" printf '%s' "$index" | jq \ --arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \ - --arg src_base "$base" --arg src_channel "$(lpReleaseChannel)" \ --argjson defined "$defined" --argjson installed "$installed" --argjson icons "$icons_map" ' { generated_at: $now, signed: ($signed=="true"), serial: ($serial|tonumber? // 0), - source: { base: $src_base, channel: $src_channel }, apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle") | (.applies_when.app // "") as $slug | select($slug != "") | { id, @@ -111,7 +109,6 @@ webuiRegistryCatalogScan() { description: (.meta.description // .why // ""), long_description: (.meta.long_description // ""), icon: ($icons[$slug] // null), - featured: (.meta.featured // false), defined: (($defined | index($slug)) != null), installed: (($installed | index($slug)) != null) } ] }' > "$tmp" 2>/dev/null