diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index 53b7504..108ac7f 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -217,60 +217,6 @@ 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 bcdd307..cb4158f 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,6 +19,20 @@ 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 3976b83..5619055 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -35,6 +35,35 @@ 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'); @@ -153,11 +182,6 @@ 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 @@ -232,36 +256,26 @@ 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} - ${statusTag} + ${status}
${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 8e9809b..ce8cd04 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -100,15 +100,16 @@ 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: on success the Available card becomes a normal Install card, on - // failure it re-renders with its Add button re-enabled. + // 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. if (action === 'app_add') { this.clearCache(); + this._mktBadgeCount = null; // catalog available-count changed — re-fetch await this.reloadAppsData(); const p = window.location.pathname; - if (p === '/apps' || p.startsWith('/apps/')) { - this.renderApps(window.appsCategory || 'all'); - } + const onGrid = p === '/apps' || (p.startsWith('/apps/') && !p.startsWith('/apps/marketplace') && !p.startsWith('/apps/overview')); + if (onGrid) this.renderApps(window.appsCategory || 'all'); return; } @@ -268,40 +269,12 @@ class AppsManager { const appsData = await response.json(); - // Filter apps by category + // 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. 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. @@ -335,9 +308,8 @@ class AppsManager { }); } - // 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)); + // Installed apps first. + filteredApps.sort((a, b) => (a.installed ? 0 : 1) - (b.installed ? 0 : 1)); // Cache the result this.cache.set(category, filteredApps); @@ -538,6 +510,7 @@ 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]; @@ -1201,18 +1174,6 @@ 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 0d05cfd..a1263e9 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -17,12 +17,42 @@ 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]; @@ -126,6 +156,8 @@ 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 new file mode 100644 index 0000000..4239e67 --- /dev/null +++ b/containers/libreportal/frontend/components/apps/marketplace/css/marketplace.css @@ -0,0 +1,127 @@ +/* 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 new file mode 100644 index 0000000..64c1bc9 --- /dev/null +++ b/containers/libreportal/frontend/components/apps/marketplace/js/marketplace-page.js @@ -0,0 +1,390 @@ +// 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 4230f46..9525bb2 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -32,6 +32,7 @@ + diff --git a/scripts/release/make_app.sh b/scripts/release/make_app.sh index 0d86194..a543fae 100644 --- a/scripts/release/make_app.sh +++ b/scripts/release/make_app.sh @@ -21,10 +21,13 @@ # channel stable (default) | edge # spec.json optional overlay for envelope fields the .config can't provide: # { "id", "version", "why", "severity", "supersedes": [], -# "publisher", "trust", "applies_when": {"min_lp","max_lp","max_footprint"} } +# "publisher", "trust", "featured": true, +# "applies_when": {"min_lp","max_lp","max_footprint"} } # Defaults: id=app-, version=auto-bump, severity=tweak, -# publisher=libreportal, trust=official. `auto` is ALWAYS false — -# apps are never auto-applied; the box enforces the same rule. +# 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. # # Catalog metadata (title/category/description/long description) comes from the # app's own .config — one source of truth with the App Center generators. @@ -147,12 +150,15 @@ 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 icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" \ + --arg featured "$FEATURED" ' {category:$category, description:$description, long_description:$long_description} - + (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end)')" + + (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end) + + (if $featured == "true" then {featured:true} 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 ca2c7c0..55b5e9f 100644 --- a/scripts/webui/data/generators/apps/webui_registry_scan.sh +++ b/scripts/webui/data/generators/apps/webui_registry_scan.sh @@ -92,10 +92,12 @@ 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, @@ -109,6 +111,7 @@ 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