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