revert: drop the dedicated Marketplace section — keep catalog merged into the grid
Reverts a4e65df. The maintainer prefers the more fluid model where registry apps flow into the App Center grid as 'Available — Add' cards alongside installed/local apps, rather than a separate /apps/marketplace destination. Restores the Stage-4 merged-grid behaviour (loadApps merges registry_catalog.json; createAppCard renders registry cards; addRegistryApp dispatches app_add). The make_app.sh featured passthrough + the generator's featured/source fields go with it (unused by the merged grid). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
81b11e5675
commit
cfb3a4f755
@ -217,6 +217,60 @@
|
|||||||
line-height: 1;
|
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 */
|
/* Clickable tags (category / installed-status) — jump to that filter view */
|
||||||
.app-tag.clickable {
|
.app-tag.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -19,20 +19,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Overview</span>
|
<span>Overview</span>
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
@ -290,10 +276,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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).
|
<!-- Fleet Overview View (Overview · Updates · Improvements · Backups).
|
||||||
Driven by OverviewManager; shares the persistent apps sidebar. -->
|
Driven by OverviewManager; shares the persistent apps sidebar. -->
|
||||||
<div id="overview-view" class="content-view">
|
<div id="overview-view" class="content-view">
|
||||||
|
|||||||
@ -35,35 +35,6 @@ Object.assign(AppsManager.prototype, {
|
|||||||
|
|
||||||
// Set active category
|
// Set active category
|
||||||
this.setActiveCategory(activeCategory);
|
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) {
|
addCategory(name, id, icon) {
|
||||||
const container = document.getElementById('dynamic-categories');
|
const container = document.getElementById('dynamic-categories');
|
||||||
@ -182,6 +153,11 @@ Object.assign(AppsManager.prototype, {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'app-card';
|
card.className = 'app-card';
|
||||||
if (app.installed) card.classList.add('installed');
|
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 +
|
// Searchable text for the sidebar search box. Combined name +
|
||||||
// description + long description + category, lowercased once here
|
// description + long description + category, lowercased once here
|
||||||
@ -256,26 +232,36 @@ Object.assign(AppsManager.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry cards: an "Available" pill + official trust badge instead of
|
||||||
|
// the install-state pill; the top area is not clickable (no detail page).
|
||||||
|
const statusTag = isRegistry
|
||||||
|
? `<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 = `
|
card.innerHTML = `
|
||||||
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
|
<div class="app-card-top" ${topAttrs}>
|
||||||
<div class="app-card-icon">
|
<div class="app-card-icon">
|
||||||
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
|
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-card-content">
|
<div class="app-card-content">
|
||||||
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
|
<div class="app-card-title" ${isRegistry ? '' : 'style="cursor: pointer;"'}>${app.name.split(' - ')[0].trim()}</div>
|
||||||
<div class="app-card-tags">
|
<div class="app-card-tags">
|
||||||
${descriptionTag}
|
${descriptionTag}
|
||||||
${categoryTag}
|
${categoryTag}
|
||||||
${instanceCountChip}
|
${instanceCountChip}
|
||||||
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
|
${statusTag}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
|
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
|
||||||
<div class="app-card-actions">
|
<div class="app-card-actions">
|
||||||
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
|
${actionButton}
|
||||||
${app.installed ? 'Manage' : 'Install'}
|
|
||||||
</button>
|
|
||||||
${serviceTrigger}
|
${serviceTrigger}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -100,16 +100,15 @@ class AppsManager {
|
|||||||
|
|
||||||
// Registry Add — the definition landed (or the add failed); either way the
|
// Registry Add — the definition landed (or the add failed); either way the
|
||||||
// card's state now comes from regenerated data, so reload + repaint 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
|
// grid: on success the Available card becomes a normal Install card, on
|
||||||
// the actual grid (not the Marketplace / Overview sub-routes, which own their
|
// failure it re-renders with its Add button re-enabled.
|
||||||
// own refresh) so we never fight another pane's render.
|
|
||||||
if (action === 'app_add') {
|
if (action === 'app_add') {
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
this._mktBadgeCount = null; // catalog available-count changed — re-fetch
|
|
||||||
await this.reloadAppsData();
|
await this.reloadAppsData();
|
||||||
const p = window.location.pathname;
|
const p = window.location.pathname;
|
||||||
const onGrid = p === '/apps' || (p.startsWith('/apps/') && !p.startsWith('/apps/marketplace') && !p.startsWith('/apps/overview'));
|
if (p === '/apps' || p.startsWith('/apps/')) {
|
||||||
if (onGrid) this.renderApps(window.appsCategory || 'all');
|
this.renderApps(window.appsCategory || 'all');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,12 +268,40 @@ class AppsManager {
|
|||||||
|
|
||||||
const appsData = await response.json();
|
const appsData = await response.json();
|
||||||
|
|
||||||
// Filter apps by category. The grid is "your apps" — definitions on this
|
// Filter apps by category
|
||||||
// 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 || [];
|
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
|
// Instances are managed inside their type's detail page (the family
|
||||||
// switcher), never as standalone grid cards — hide any app that declares
|
// switcher), never as standalone grid cards — hide any app that declares
|
||||||
// INSTANCE_OF so the grid shows one card per app type.
|
// INSTANCE_OF so the grid shows one card per app type.
|
||||||
@ -308,8 +335,9 @@ class AppsManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installed apps first.
|
// Sort: installed first, then local not-installed, registry cards last.
|
||||||
filteredApps.sort((a, b) => (a.installed ? 0 : 1) - (b.installed ? 0 : 1));
|
const rank = (a) => a.installed ? 0 : (a.registry ? 2 : 1);
|
||||||
|
filteredApps.sort((a, b) => rank(a) - rank(b));
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
this.cache.set(category, filteredApps);
|
this.cache.set(category, filteredApps);
|
||||||
@ -510,7 +538,6 @@ class AppsManager {
|
|||||||
'apps': document.getElementById('apps-view'),
|
'apps': document.getElementById('apps-view'),
|
||||||
'app-detail': document.getElementById('app-detail-view'),
|
'app-detail': document.getElementById('app-detail-view'),
|
||||||
'overview': document.getElementById('overview-view'),
|
'overview': document.getElementById('overview-view'),
|
||||||
'marketplace': document.getElementById('marketplace-view'),
|
|
||||||
};
|
};
|
||||||
Object.keys(views).forEach((key) => {
|
Object.keys(views).forEach((key) => {
|
||||||
const el = views[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) {
|
async installApp(appName) {
|
||||||
const installedApp = (window.apps || []).find(a =>
|
const installedApp = (window.apps || []).find(a =>
|
||||||
(a.command || '').endsWith(` ${appName}`) || a.name === appName
|
(a.command || '').endsWith(` ${appName}`) || a.name === appName
|
||||||
|
|||||||
@ -17,42 +17,12 @@ LP.features.register({
|
|||||||
if (window.location.pathname.startsWith('/apps/overview')) {
|
if (window.location.pathname.startsWith('/apps/overview')) {
|
||||||
return this._mountOverview(ctx);
|
return this._mountOverview(ctx);
|
||||||
}
|
}
|
||||||
if (window.location.pathname.startsWith('/apps/marketplace')) {
|
|
||||||
return this._mountMarketplace(ctx);
|
|
||||||
}
|
|
||||||
if (window.location.pathname.startsWith('/apps')) {
|
if (window.location.pathname.startsWith('/apps')) {
|
||||||
return this._mountGrid(ctx);
|
return this._mountGrid(ctx);
|
||||||
}
|
}
|
||||||
return this._mountDetail(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>) ----
|
// ---- grid (/apps, /apps/<category>) ----
|
||||||
async _mountGrid(ctx) {
|
async _mountGrid(ctx) {
|
||||||
const seg = window.location.pathname.replace(/^\/apps\/?/, '').split('/')[0];
|
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()
|
// overviewManager singleton + its DOM persist with the layout; its run()
|
||||||
// self-guards, but unregistering is the clean release.
|
// self-guards, but unregistering is the clean release.
|
||||||
try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {}
|
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
|
// Same for the headless updater's auto-refresh timer (re-armed on the next
|
||||||
// Overview mount).
|
// Overview mount).
|
||||||
try { window.overviewManager && window.overviewManager.updater && window.overviewManager.updater.dispose(); } catch (_) {}
|
try { window.overviewManager && window.overviewManager.updater && window.overviewManager.updater.dispose(); } catch (_) {}
|
||||||
|
|||||||
@ -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); }
|
|
||||||
@ -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 <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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeAttr(s) { return this.escape(s); }
|
|
||||||
}
|
|
||||||
|
|
||||||
window.MarketplacePage = MarketplacePage;
|
|
||||||
@ -32,7 +32,6 @@
|
|||||||
<link rel="stylesheet" href="/components/apps/core/css/apps-layout.css">
|
<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/core/css/apps.css">
|
||||||
<link rel="stylesheet" href="/components/apps/overview/css/overview.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/forms.css">
|
||||||
<link rel="stylesheet" href="/core/forms/css/config.css">
|
<link rel="stylesheet" href="/core/forms/css/config.css">
|
||||||
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">
|
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">
|
||||||
|
|||||||
@ -21,13 +21,10 @@
|
|||||||
# channel stable (default) | edge
|
# channel stable (default) | edge
|
||||||
# spec.json optional overlay for envelope fields the .config can't provide:
|
# spec.json optional overlay for envelope fields the .config can't provide:
|
||||||
# { "id", "version", "why", "severity", "supersedes": [],
|
# { "id", "version", "why", "severity", "supersedes": [],
|
||||||
# "publisher", "trust", "featured": true,
|
# "publisher", "trust", "applies_when": {"min_lp","max_lp","max_footprint"} }
|
||||||
# "applies_when": {"min_lp","max_lp","max_footprint"} }
|
|
||||||
# Defaults: id=app-<slug>, version=auto-bump, severity=tweak,
|
# Defaults: id=app-<slug>, version=auto-bump, severity=tweak,
|
||||||
# publisher=libreportal, trust=official, featured=false (the
|
# publisher=libreportal, trust=official. `auto` is ALWAYS false —
|
||||||
# curated Marketplace shelf — set at publish time, no tracking).
|
# apps are never auto-applied; the box enforces the same rule.
|
||||||
# `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
|
# Catalog metadata (title/category/description/long description) comes from the
|
||||||
# app's own .config — one source of truth with the App Center generators.
|
# app's own .config — one source of truth with the App Center generators.
|
||||||
@ -150,15 +147,12 @@ for ext in svg png; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
FEATURED="$(specget '.featured')"
|
|
||||||
META="$(jq -cn \
|
META="$(jq -cn \
|
||||||
--arg category "$CATEGORY" --arg description "$DESCRIPTION" \
|
--arg category "$CATEGORY" --arg description "$DESCRIPTION" \
|
||||||
--arg long_description "$LONG_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}
|
{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") -------
|
# --- build the envelope (§8.1 shape; type:"app", payload.kind:"bundle") -------
|
||||||
envelope="$(jq -cn \
|
envelope="$(jq -cn \
|
||||||
|
|||||||
@ -92,12 +92,10 @@ webuiRegistryCatalogScan() {
|
|||||||
local tmp; tmp="$(mktemp)"
|
local tmp; tmp="$(mktemp)"
|
||||||
printf '%s' "$index" | jq \
|
printf '%s' "$index" | jq \
|
||||||
--arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \
|
--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" '
|
--argjson defined "$defined" --argjson installed "$installed" --argjson icons "$icons_map" '
|
||||||
{ generated_at: $now,
|
{ generated_at: $now,
|
||||||
signed: ($signed=="true"),
|
signed: ($signed=="true"),
|
||||||
serial: ($serial|tonumber? // 0),
|
serial: ($serial|tonumber? // 0),
|
||||||
source: { base: $src_base, channel: $src_channel },
|
|
||||||
apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle")
|
apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle")
|
||||||
| (.applies_when.app // "") as $slug | select($slug != "")
|
| (.applies_when.app // "") as $slug | select($slug != "")
|
||||||
| { id,
|
| { id,
|
||||||
@ -111,7 +109,6 @@ webuiRegistryCatalogScan() {
|
|||||||
description: (.meta.description // .why // ""),
|
description: (.meta.description // .why // ""),
|
||||||
long_description: (.meta.long_description // ""),
|
long_description: (.meta.long_description // ""),
|
||||||
icon: ($icons[$slug] // null),
|
icon: ($icons[$slug] // null),
|
||||||
featured: (.meta.featured // false),
|
|
||||||
defined: (($defined | index($slug)) != null),
|
defined: (($defined | index($slug)) != null),
|
||||||
installed: (($installed | index($slug)) != null) } ] }' > "$tmp" 2>/dev/null
|
installed: (($installed | index($slug)) != null) } ] }' > "$tmp" 2>/dev/null
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user