`;
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 = `
+