feat(webui/marketplace): dedicated Marketplace section in the App Center

Splits the catalog out of the main grid into its own destination, the
WordPress 'Add Plugins' vs 'Installed Plugins' model: the grid is now
purely 'your apps' (local definitions), and the new Marketplace section is
'get more apps' (the remote signed catalog).

- New MarketplacePage (components/apps/marketplace/) mounts at
  /apps/marketplace inside the apps feature (same sub-dispatch pattern as
  /apps/overview — no new top-level component). Pinned sidebar entry with a
  live 'available to add' count badge.
- Status strip: signed/unsigned, available + catalog counts, serial, source
  base+channel, freshness, and a Refresh that re-runs the host-side registry
  scan via updater_check.
- Publisher-curated Featured shelf (meta.featured, set at publish time — no
  tracking/popularity), category chips + search, per-app detail modal
  (long description, publisher/trust/version, add command), and the chained
  Add & set-up flow: dispatch app_add, and when the definition lands, hand
  off to the app's config/install page.
- State-aware cards: Available (Add) / Added (Set up →) / Installed (Open).
- Backend: make_app.sh passes through meta.featured; webui_registry_scan.sh
  emits featured + source{base,channel} in registry_catalog.json.
- Removed the grid's registry-merge + registry card path + its CSS (moved to
  the namespaced marketplace surface); app_add task wiring + completion
  handler retained and reused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-04 21:38:12 +01:00
parent d79a999c4f
commit a4e65df77f
10 changed files with 630 additions and 132 deletions

View File

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

View File

@ -19,6 +19,20 @@
</svg>
<span>Overview</span>
</div>
<!-- Marketplace entry — opens the signed-catalog browse-and-add
destination in the main pane. A count badge (apps available to add)
is injected by MarketplacePage after the catalog loads. -->
<div class="sidebar-overview-entry sidebar-marketplace-entry" id="sidebar-marketplace-entry" role="button" tabindex="0"
onclick="if(window.navigateToRoute){window.navigateToRoute('/apps/marketplace');}else{window.location.href='/apps/marketplace';}"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}">
<svg class="mkt-entry-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 9l1.5-5h15L21 9"></path>
<path d="M4 9v10a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V9"></path>
<path d="M3 9h18"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
<span>Marketplace</span>
</div>
<div class="apps-search">
<svg class="apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
@ -276,6 +290,10 @@
</div>
</div>
<!-- Marketplace View — the signed-catalog browse-and-add destination.
Driven by MarketplacePage; shares the persistent apps sidebar. -->
<div id="marketplace-view" class="content-view"></div>
<!-- Fleet Overview View (Overview · Updates · Improvements · Backups).
Driven by OverviewManager; shares the persistent apps sidebar. -->
<div id="overview-view" class="content-view">

View File

@ -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
? `<span class="app-tag available-tag">Available</span>${app.trust === 'official' ? `<span class="app-tag trust-badge" title="Published and signed by ${app.publisher || 'LibrePortal'}">Official</span>` : ''}`
: `<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>`;
const topAttrs = isRegistry ? '' : `style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')"`;
const actionButton = isRegistry
? `<button class="add-btn" onclick="appsManager.addRegistryApp('${app.slug}', this)">Add</button>`
: `<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
${app.installed ? 'Manage' : 'Install'}
</button>`;
card.innerHTML = `
<div class="app-card-top" ${topAttrs}>
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
<div class="app-card-icon">
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
</div>
<div class="app-card-content">
<div class="app-card-title" ${isRegistry ? '' : 'style="cursor: pointer;"'}>${app.name.split(' - ')[0].trim()}</div>
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
<div class="app-card-tags">
${descriptionTag}
${categoryTag}
${instanceCountChip}
${statusTag}
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
</div>
</div>
</div>
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
<div class="app-card-actions">
${actionButton}
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
${app.installed ? 'Manage' : 'Install'}
</button>
${serviceTrigger}
</div>
`;

View File

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

View File

@ -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/<category>) ----
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 (_) {}

View File

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

View File

@ -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 <slug>) 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/<slug>
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 = `<span class="mkt-stat mkt-stat-none">No catalog yet — the box hasn't fetched a registry, or none is published.</span>`;
} 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
? `<span class="mkt-stat mkt-ok" title="The catalog's signature was verified against the box's signing key">✓ signed</span>`
: `<span class="mkt-stat mkt-warn" title="Signing isn't active on this channel — apps can be browsed but adding is disabled">unsigned</span>`;
const when = c.generated_at ? this.fmtRel(c.generated_at) : '—';
status = `${sig}
<span class="mkt-stat">${avail} available</span>
<span class="mkt-stat">${total} in catalog</span>
<span class="mkt-stat mkt-dim">serial ${this.escape(String(c.serial != null ? c.serial : '?'))}</span>
<span class="mkt-stat mkt-dim">source ${src}</span>
<span class="mkt-stat mkt-dim">refreshed ${this.escape(when)}</span>`;
}
root.innerHTML = `
<div class="mkt">
<div class="config-title mkt-hero">
<div class="mkt-hero-main">
<h3>🛒 Marketplace</h3>
<p>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.</p>
</div>
<div class="mkt-hero-actions">
<button class="updater-btn" data-mkt-action="refresh"> Refresh</button>
</div>
</div>
<div class="mkt-statusbar">${status}</div>
<div class="mkt-body" id="mkt-body"></div>
</div>`;
this.renderBody();
}
renderBody() {
const host = document.getElementById('mkt-body');
if (!host) return;
if (!this.catalog) {
host.innerHTML = this.empty('Nothing to browse yet. Point <code>CFG_RELEASE_BASE_URL</code> 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) =>
`<button class="mkt-chip${this.cat === id ? ' active' : ''}" data-mkt-action="chip" data-cat="${this.escape(id)}">${this.escape(label)}</button>`;
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 = `
<div class="mkt-featured">
<div class="mkt-shelf-title"> Featured</div>
<div class="mkt-grid mkt-grid-featured">${feat.map((a) => this.card(a, true)).join('')}</div>
</div>`;
}
}
const grid = shown.length
? `<div class="mkt-grid">${shown.map((a) => this.card(a, false)).join('')}</div>`
: this.empty('Nothing matches your search.');
host.innerHTML = `
<div class="mkt-toolbar">
<div class="mkt-search-wrap">
<input type="search" id="mkt-search" class="mkt-search" placeholder="Search the catalog…" autocomplete="off" spellcheck="false" value="${this.escapeAttr(this.q)}">
</div>
<div class="mkt-chips">${chips}</div>
</div>
${featuredHtml}
<div class="mkt-shelf-title mkt-all-title">${this.cat === 'all' ? 'All apps' : this.titleCase(this.cat)}</div>
${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'
? `<span class="mkt-badge mkt-badge-official" title="Published and signed by ${this.escapeAttr(a.publisher || 'LibrePortal')}">Official</span>`
: `<span class="mkt-badge mkt-badge-community">${this.escape(a.trust || 'community')}</span>`;
let action;
if (st === 'installed') {
action = `<button class="mkt-btn mkt-btn-ghost" data-mkt-action="open" data-slug="${this.escapeAttr(a.app)}">Open</button>`;
} else if (st === 'added') {
action = `<button class="mkt-btn mkt-btn-setup" data-mkt-action="setup" data-slug="${this.escapeAttr(a.app)}">Set up →</button>`;
} else if (this.catalog && this.catalog.signed) {
action = `<button class="mkt-btn mkt-btn-add" data-mkt-action="add" data-slug="${this.escapeAttr(a.app)}">Add</button>`;
} else {
action = `<button class="mkt-btn mkt-btn-ghost" disabled title="Adding needs a signed catalog">Add</button>`;
}
const stateTag = st === 'installed'
? `<span class="mkt-tag mkt-tag-installed">Installed</span>`
: (st === 'added' ? `<span class="mkt-tag mkt-tag-added">Added</span>` : `<span class="mkt-tag mkt-tag-avail">Available</span>`);
return `
<div class="mkt-card${featured ? ' mkt-card-featured' : ''}" role="button" tabindex="0" data-mkt-action="details" data-slug="${this.escapeAttr(a.app)}">
<div class="mkt-card-top">
<div class="mkt-card-icon"><img src="${icon}" alt="" onerror="this.onerror=null;this.src='/core/icons/apps/default.svg'"></div>
<div class="mkt-card-head">
<div class="mkt-card-title">${this.escape(a.title || a.app)}</div>
<div class="mkt-card-tags">
<span class="mkt-tag">${this.escape(a.category || 'app')}</span>
${trust}
${stateTag}
</div>
</div>
</div>
<div class="mkt-card-desc">${this.escape(a.long_description || a.description || '')}</div>
<div class="mkt-card-actions">
<span class="mkt-card-ver">v${this.escape(String(a.version != null ? a.version : 1))}</span>
${action}
</div>
</div>`;
}
// ---- 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', `<p class="mkt-modal-desc">${this.escape(a.long_description || a.description || 'No description provided.')}</p>`) : `<p>${this.escape(a.long_description || a.description || '')}</p>`,
window.eoSection ? window.eoSection('Add command', `<code class="mkt-cmd">libreportal app add ${this.escape(a.app)}</code>`) : '',
];
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 `<div class="mkt-empty">${html}</div>`; }
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
escapeAttr(s) { return this.escape(s); }
}
window.MarketplacePage = MarketplacePage;

View File

@ -32,6 +32,7 @@
<link rel="stylesheet" href="/components/apps/core/css/apps-layout.css">
<link rel="stylesheet" href="/components/apps/core/css/apps.css">
<link rel="stylesheet" href="/components/apps/overview/css/overview.css">
<link rel="stylesheet" href="/components/apps/marketplace/css/marketplace.css">
<link rel="stylesheet" href="/core/forms/css/forms.css">
<link rel="stylesheet" href="/core/forms/css/config.css">
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">

View File

@ -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-<slug>, 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 \

View File

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