Merge claude/2
This commit is contained in:
commit
cac431ac21
@ -217,6 +217,60 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Registry catalog ("marketplace") cards — apps published in the signed index
|
||||
but not on this box yet. Indigo family: distinct from green (installed),
|
||||
gray (not installed) and amber (improvements). */
|
||||
.app-card.registry {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.app-tag.available-tag {
|
||||
background: rgba(99, 102, 241, 0.30);
|
||||
color: #a5b4fc;
|
||||
border-color: rgba(99, 102, 241, 0.65);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-tag.available-tag::before {
|
||||
content: '↓';
|
||||
margin-right: 5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Official trust badge — teal check: this entry is published and signed by
|
||||
the LibrePortal team key (the box verified the signature). */
|
||||
.app-tag.trust-badge {
|
||||
background: rgba(34, 211, 238, 0.22);
|
||||
color: #67e8f9;
|
||||
border-color: rgba(34, 211, 238, 0.55);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-tag.trust-badge::before {
|
||||
content: '✓';
|
||||
margin-right: 5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The Add button — indigo to match the Available pill (install stays green). */
|
||||
.app-card-actions button[class*="add-btn"] {
|
||||
background: #6366f1 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #6366f1 !important;
|
||||
}
|
||||
|
||||
.app-card-actions button[class*="add-btn"]:hover {
|
||||
background: #4f46e5 !important;
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.app-card-actions button[class*="add-btn"]:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Clickable tags (category / installed-status) — jump to that filter view */
|
||||
.app-tag.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@ -19,20 +19,6 @@
|
||||
</svg>
|
||||
<span>Overview</span>
|
||||
</div>
|
||||
<!-- Marketplace entry — opens the signed-catalog browse-and-add
|
||||
destination in the main pane. A count badge (apps available to add)
|
||||
is injected by MarketplacePage after the catalog loads. -->
|
||||
<div class="sidebar-overview-entry sidebar-marketplace-entry" id="sidebar-marketplace-entry" role="button" tabindex="0"
|
||||
onclick="if(window.navigateToRoute){window.navigateToRoute('/apps/marketplace');}else{window.location.href='/apps/marketplace';}"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}">
|
||||
<svg class="mkt-entry-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 9l1.5-5h15L21 9"></path>
|
||||
<path d="M4 9v10a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V9"></path>
|
||||
<path d="M3 9h18"></path>
|
||||
<path d="M9 20v-6h6v6"></path>
|
||||
</svg>
|
||||
<span>Marketplace</span>
|
||||
</div>
|
||||
<div class="apps-search">
|
||||
<svg class="apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
@ -290,10 +276,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace View — the signed-catalog browse-and-add destination.
|
||||
Driven by MarketplacePage; shares the persistent apps sidebar. -->
|
||||
<div id="marketplace-view" class="content-view"></div>
|
||||
|
||||
<!-- Fleet Overview View (Overview · Updates · Improvements · Backups).
|
||||
Driven by OverviewManager; shares the persistent apps sidebar. -->
|
||||
<div id="overview-view" class="content-view">
|
||||
|
||||
@ -35,35 +35,6 @@ Object.assign(AppsManager.prototype, {
|
||||
|
||||
// Set active category
|
||||
this.setActiveCategory(activeCategory);
|
||||
|
||||
// Refresh the Marketplace sidebar badge (apps available to add). Best-effort
|
||||
// and cached — the sidebar persists across apps sub-routes.
|
||||
this.updateMarketplaceBadge();
|
||||
},
|
||||
// Show "N" on the Marketplace sidebar entry = catalog apps not yet on this box.
|
||||
// No badge when the catalog is absent or nothing is addable.
|
||||
updateMarketplaceBadge() {
|
||||
const entry = document.getElementById('sidebar-marketplace-entry');
|
||||
if (!entry) return;
|
||||
const paint = (n) => {
|
||||
let badge = entry.querySelector('.mkt-entry-badge');
|
||||
if (!n) { if (badge) badge.remove(); return; }
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'mkt-entry-badge';
|
||||
entry.appendChild(badge);
|
||||
}
|
||||
badge.textContent = String(n);
|
||||
};
|
||||
if (this._mktBadgeCount != null) { paint(this._mktBadgeCount); return; }
|
||||
fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => {
|
||||
const n = (c && Array.isArray(c.apps)) ? c.apps.filter((a) => !a.defined && !a.installed).length : 0;
|
||||
this._mktBadgeCount = n;
|
||||
paint(n);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
addCategory(name, id, icon) {
|
||||
const container = document.getElementById('dynamic-categories');
|
||||
@ -182,6 +153,11 @@ Object.assign(AppsManager.prototype, {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'app-card';
|
||||
if (app.installed) card.classList.add('installed');
|
||||
// Registry catalog card ("Available — Add"): no local definition exists
|
||||
// yet, so there is no detail page to open — the card is the whole surface
|
||||
// until Add lands the definition and it becomes a normal Install card.
|
||||
const isRegistry = !!app.registry;
|
||||
if (isRegistry) card.classList.add('registry');
|
||||
|
||||
// Searchable text for the sidebar search box. Combined name +
|
||||
// description + long description + category, lowercased once here
|
||||
@ -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 = `
|
||||
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
|
||||
<div class="app-card-top" ${topAttrs}>
|
||||
<div class="app-card-icon">
|
||||
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
|
||||
</div>
|
||||
<div class="app-card-content">
|
||||
<div class="app-card-title" 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">
|
||||
${descriptionTag}
|
||||
${categoryTag}
|
||||
${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>
|
||||
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
|
||||
<div class="app-card-actions">
|
||||
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
|
||||
${app.installed ? 'Manage' : 'Install'}
|
||||
</button>
|
||||
${actionButton}
|
||||
${serviceTrigger}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<category>) ----
|
||||
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 (_) {}
|
||||
|
||||
@ -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.css">
|
||||
<link rel="stylesheet" href="/components/apps/overview/css/overview.css">
|
||||
<link rel="stylesheet" href="/components/apps/marketplace/css/marketplace.css">
|
||||
<link rel="stylesheet" href="/core/forms/css/forms.css">
|
||||
<link rel="stylesheet" href="/core/forms/css/config.css">
|
||||
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">
|
||||
|
||||
@ -21,13 +21,10 @@
|
||||
# channel stable (default) | edge
|
||||
# spec.json optional overlay for envelope fields the .config can't provide:
|
||||
# { "id", "version", "why", "severity", "supersedes": [],
|
||||
# "publisher", "trust", "featured": true,
|
||||
# "applies_when": {"min_lp","max_lp","max_footprint"} }
|
||||
# "publisher", "trust", "applies_when": {"min_lp","max_lp","max_footprint"} }
|
||||
# Defaults: id=app-<slug>, version=auto-bump, severity=tweak,
|
||||
# publisher=libreportal, trust=official, featured=false (the
|
||||
# curated Marketplace shelf — set at publish time, no tracking).
|
||||
# `auto` is ALWAYS false — apps are never auto-applied; the box
|
||||
# enforces the same rule.
|
||||
# publisher=libreportal, trust=official. `auto` is ALWAYS false —
|
||||
# apps are never auto-applied; the box enforces the same rule.
|
||||
#
|
||||
# Catalog metadata (title/category/description/long description) comes from the
|
||||
# app's own .config — one source of truth with the App Center generators.
|
||||
@ -150,15 +147,12 @@ for ext in svg png; do
|
||||
fi
|
||||
done
|
||||
|
||||
FEATURED="$(specget '.featured')"
|
||||
META="$(jq -cn \
|
||||
--arg category "$CATEGORY" --arg description "$DESCRIPTION" \
|
||||
--arg long_description "$LONG_DESCRIPTION" \
|
||||
--arg icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" \
|
||||
--arg featured "$FEATURED" '
|
||||
--arg icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" '
|
||||
{category:$category, description:$description, long_description:$long_description}
|
||||
+ (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end)
|
||||
+ (if $featured == "true" then {featured:true} else {} end)')"
|
||||
+ (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end)')"
|
||||
|
||||
# --- build the envelope (§8.1 shape; type:"app", payload.kind:"bundle") -------
|
||||
envelope="$(jq -cn \
|
||||
|
||||
@ -92,12 +92,10 @@ webuiRegistryCatalogScan() {
|
||||
local tmp; tmp="$(mktemp)"
|
||||
printf '%s' "$index" | jq \
|
||||
--arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \
|
||||
--arg src_base "$base" --arg src_channel "$(lpReleaseChannel)" \
|
||||
--argjson defined "$defined" --argjson installed "$installed" --argjson icons "$icons_map" '
|
||||
{ generated_at: $now,
|
||||
signed: ($signed=="true"),
|
||||
serial: ($serial|tonumber? // 0),
|
||||
source: { base: $src_base, channel: $src_channel },
|
||||
apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle")
|
||||
| (.applies_when.app // "") as $slug | select($slug != "")
|
||||
| { id,
|
||||
@ -111,7 +109,6 @@ webuiRegistryCatalogScan() {
|
||||
description: (.meta.description // .why // ""),
|
||||
long_description: (.meta.long_description // ""),
|
||||
icon: ($icons[$slug] // null),
|
||||
featured: (.meta.featured // false),
|
||||
defined: (($defined | index($slug)) != null),
|
||||
installed: (($installed | index($slug)) != null) } ] }' > "$tmp" 2>/dev/null
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user