feat(webui/apps): registry Add opens a detail modal with a marketplace link
Replaces the extra on-card marketplace tags (the separate Official trust
badge) with a richer, more elegant flow: a registry card keeps a single
'Available' pill, and clicking Add (or the card) opens a modal outlining the
app — full description, publisher + trust, version, the add command, and a
'View full page on the marketplace ↗' link to the app's page on the source
it came from — with the actual add as the confirm. Gives a beat to review a
community app before pulling it in.
- webui_registry_scan.sh re-emits source{base,channel}; loadApps stashes it
(window.registryCatalogSource) + carries per-app version.
- openRegistryDetails() builds the eo-modal; addRegistryApp() is now just the
task dispatch (the modal's confirm / a fallback).
- The marketplace website supports a #<slug> deep-link (focus one app, with a
'Show all' bar), so the modal's link lands on that app's page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
88bdb4d63a
commit
cc36d86e57
@ -271,6 +271,14 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Registry "Add" detail modal bits (openRegistryDetails). */
|
||||
.mkt-detail-about { color: var(--text-secondary); line-height: 1.55; margin: 0; }
|
||||
.mkt-detail-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;
|
||||
}
|
||||
.mkt-detail-link { font-weight: 600; }
|
||||
|
||||
/* Clickable tags (category / installed-status) — jump to that filter view */
|
||||
.app-tag.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@ -232,14 +232,17 @@ 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).
|
||||
// Registry cards: a single "Available" status pill (the richer detail —
|
||||
// publisher, trust, version, the marketplace link — lives in the Add modal,
|
||||
// so the card stays clean). Clicking the card top OR Add opens that modal.
|
||||
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 available-tag">Available</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 topAttrs = isRegistry
|
||||
? `style="cursor: pointer;" onclick="appsManager.openRegistryDetails('${app.slug}')"`
|
||||
: `style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')"`;
|
||||
const actionButton = isRegistry
|
||||
? `<button class="add-btn" onclick="appsManager.addRegistryApp('${app.slug}', this)">Add</button>`
|
||||
? `<button class="add-btn" onclick="event.stopPropagation(); appsManager.openRegistryDetails('${app.slug}')">Add</button>`
|
||||
: `<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
|
||||
${app.installed ? 'Manage' : 'Install'}
|
||||
</button>`;
|
||||
@ -250,7 +253,7 @@ Object.assign(AppsManager.prototype, {
|
||||
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
|
||||
</div>
|
||||
<div class="app-card-content">
|
||||
<div class="app-card-title" ${isRegistry ? '' : 'style="cursor: pointer;"'}>${app.name.split(' - ')[0].trim()}</div>
|
||||
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
|
||||
<div class="app-card-tags">
|
||||
${descriptionTag}
|
||||
${categoryTag}
|
||||
|
||||
@ -279,6 +279,9 @@ class AppsManager {
|
||||
const regRes = await fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' });
|
||||
if (regRes.ok) {
|
||||
const reg = await regRes.json();
|
||||
// Stash the catalog source (base URL + channel) so the Add modal can
|
||||
// link to the app's full page on the marketplace it came from.
|
||||
window.registryCatalogSource = reg.source || null;
|
||||
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))
|
||||
@ -292,6 +295,7 @@ class AppsManager {
|
||||
registry: true,
|
||||
artifactId: e.id,
|
||||
slug: e.app,
|
||||
version: e.version || 1,
|
||||
trust: e.trust || 'official',
|
||||
publisher: e.publisher || '',
|
||||
icon: e.icon || '/core/icons/apps/default.svg',
|
||||
@ -1201,18 +1205,73 @@ 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) {
|
||||
// Registry catalog "Add" — the elegant path: open a detail modal (full
|
||||
// description, publisher, trust, version, a link to the app's page on the
|
||||
// marketplace it came from) with the actual add as the confirm. Keeps the
|
||||
// grid card clean and gives a beat to review a community app before pulling
|
||||
// it in. The card carries the registry fields (loadApps mapping).
|
||||
openRegistryDetails(slug) {
|
||||
if (!/^[a-z0-9][a-z0-9_]{0,31}$/.test(String(slug || ''))) return;
|
||||
const a = (window.apps || []).find(x => x.registry && x.slug === slug);
|
||||
if (!a) { this.addRegistryApp(slug); return; } // data missing — fall back to a plain add
|
||||
if (typeof window.openEoModal !== 'function') { this.addRegistryApp(slug); return; }
|
||||
|
||||
const official = a.trust === 'official';
|
||||
const badges = (typeof window.eoBadgeRow === 'function') ? window.eoBadgeRow([
|
||||
{ icon: '🏷️', label: a.category || 'app', variant: 'info' },
|
||||
{ icon: official ? '✓' : '•', label: official ? `Official — signed by ${a.publisher || 'LibrePortal'}` : `Community — ${a.publisher || 'unverified'}`, variant: official ? 'success' : 'warning' },
|
||||
{ icon: '⑆', label: `v${a.version || 1}`, variant: 'purple' },
|
||||
]) : '';
|
||||
const sec = (typeof window.eoSection === 'function') ? window.eoSection : (t, c) => `<div><strong>${t}</strong>${c}</div>`;
|
||||
const longDesc = a.longDescription || a.description || 'No description provided.';
|
||||
|
||||
// Link to the app's full page on the marketplace it came from, when the
|
||||
// catalog told us its source base (host that serves the browse site).
|
||||
const src = window.registryCatalogSource;
|
||||
let linkHtml = '';
|
||||
if (src && src.base && /^https?:\/\//.test(src.base)) {
|
||||
const url = `${src.base.replace(/\/+$/, '')}/#${encodeURIComponent(slug)}`;
|
||||
linkHtml = sec('Marketplace', `<a href="${this._esc(url)}" target="_blank" rel="noopener noreferrer" class="mkt-detail-link">View full page on the marketplace ↗</a>`);
|
||||
}
|
||||
|
||||
const body = [
|
||||
badges,
|
||||
sec('About', `<p class="mkt-detail-about">${this._esc(longDesc)}</p>`),
|
||||
linkHtml,
|
||||
sec('Adds', `<code class="mkt-detail-cmd">libreportal app add ${this._esc(slug)}</code>`),
|
||||
].filter(Boolean);
|
||||
|
||||
window.openEoModal({
|
||||
id: 'registry-add-modal',
|
||||
size: 'md',
|
||||
icon: a.icon || '/core/icons/apps/default.svg',
|
||||
eyebrow: official ? 'Official app' : 'Community app',
|
||||
title: (a.name || slug).split(' - ')[0].trim(),
|
||||
desc: a.description || '',
|
||||
body,
|
||||
actions: [
|
||||
{ label: 'Add to my apps', variant: 'primary', onClick: (m) => { m.close(); this.addRegistryApp(slug); } },
|
||||
{ label: 'Cancel', variant: 'ghost', onClick: (m) => m.close() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch the app_add task that verifies the signed bundle and drops the
|
||||
// definition in (mutations via tasks, as ever). Called from the Add modal's
|
||||
// confirm (and as a fallback when the modal can't open).
|
||||
addRegistryApp(slug) {
|
||||
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 });
|
||||
}
|
||||
|
||||
_esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async installApp(appName) {
|
||||
const installedApp = (window.apps || []).find(a =>
|
||||
(a.command || '').endsWith(` ${appName}`) || a.name === appName
|
||||
|
||||
@ -147,6 +147,12 @@
|
||||
.app-card-actions button.copied { background: var(--status-success); border-color: var(--status-success); }
|
||||
.app-card-actions button.installed-btn { background: transparent; border-color: var(--border-color); color: var(--text-secondary); cursor: default; }
|
||||
|
||||
.focusbar {
|
||||
display: flex; align-items: center; gap: 12px; margin: 16px 22px 0; padding: 10px 16px;
|
||||
background: rgba(99,102,241,0.14); border: 1px solid rgba(99,102,241,0.4); border-radius: 12px; font-size: 0.9rem;
|
||||
}
|
||||
.focusbar button { background: transparent; border: 1px solid var(--border-color); color: var(--text-primary); padding: 5px 12px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
|
||||
.focusbar button:hover { background: rgba(var(--text-rgb),0.08); }
|
||||
.empty, .footnote { color: var(--text-muted); }
|
||||
.empty { text-align: center; padding: 48px 20px; margin: 22px; border: 1px dashed var(--border-color); border-radius: 12px; }
|
||||
.footnote { margin: 4px 22px 40px; font-size: 0.85rem; line-height: 1.6; }
|
||||
@ -172,6 +178,7 @@
|
||||
|
||||
<div class="main-content">
|
||||
<div class="status-strip" id="status"></div>
|
||||
<div class="focusbar" id="focusbar" hidden></div>
|
||||
<div class="apps-section" id="grid"></div>
|
||||
<div class="empty" id="empty" hidden>Nothing published on this channel yet.</div>
|
||||
<p class="footnote">
|
||||
@ -188,7 +195,11 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var CHANNELS = ['stable', 'edge'];
|
||||
var state = { apps: [], cat: 'all', q: '' };
|
||||
function readFocus() {
|
||||
var h = (window.location.hash || '').replace(/^#/, '');
|
||||
return /^[a-z0-9][a-z0-9_]{0,31}$/.test(h) ? h : '';
|
||||
}
|
||||
var state = { apps: [], cat: 'all', q: '', focus: readFocus() };
|
||||
var grid = document.getElementById('grid');
|
||||
var cats = document.getElementById('cats');
|
||||
var empty = document.getElementById('empty');
|
||||
@ -254,15 +265,31 @@
|
||||
|
||||
function render() {
|
||||
var q = state.q.toLowerCase();
|
||||
var focus = state.focus; // #<slug> deep-link → show just that app
|
||||
var shown = state.apps.filter(function (a) {
|
||||
if (focus) return a.slug === focus;
|
||||
if (state.cat !== 'all' && a.category !== state.cat) return false;
|
||||
if (q && (a.title + ' ' + a.description + ' ' + a.long_description + ' ' + a.slug).toLowerCase().indexOf(q) < 0) return false;
|
||||
return true;
|
||||
});
|
||||
var fb = document.getElementById('focusbar');
|
||||
if (focus) {
|
||||
var one = state.apps.filter(function (a) { return a.slug === focus; })[0];
|
||||
fb.innerHTML = '<span>Linked from the marketplace: <strong>' + esc(one ? one.title : focus) + '</strong></span>' +
|
||||
'<button id="showall">← Show all apps</button>';
|
||||
fb.hidden = false;
|
||||
} else { fb.hidden = true; fb.innerHTML = ''; }
|
||||
grid.innerHTML = shown.map(card).join('');
|
||||
grid.style.display = shown.length ? '' : 'none';
|
||||
empty.hidden = shown.length > 0;
|
||||
if (state.q || state.cat !== 'all') { empty.textContent = 'Nothing matches your filter.'; }
|
||||
if (focus && !shown.length) empty.textContent = 'That app isn’t on this channel.';
|
||||
else if (state.q || state.cat !== 'all') empty.textContent = 'Nothing matches your filter.';
|
||||
}
|
||||
|
||||
function clearFocus() {
|
||||
if (!state.focus) return;
|
||||
state.focus = '';
|
||||
if (window.location.hash) { try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) { window.location.hash = ''; } }
|
||||
}
|
||||
|
||||
function setStatus(idx, ch, signed) {
|
||||
@ -308,8 +335,12 @@
|
||||
|
||||
cats.addEventListener('click', function (e) {
|
||||
var el = e.target.closest('[data-cat]'); if (!el) return;
|
||||
state.cat = el.getAttribute('data-cat'); renderCats(); render();
|
||||
clearFocus(); state.cat = el.getAttribute('data-cat'); renderCats(); render();
|
||||
});
|
||||
document.getElementById('focusbar').addEventListener('click', function (e) {
|
||||
if (e.target.closest('#showall')) { clearFocus(); render(); }
|
||||
});
|
||||
window.addEventListener('hashchange', function () { state.focus = readFocus(); render(); });
|
||||
grid.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-slug]'); if (!b) return;
|
||||
var txt = 'libreportal app add ' + b.getAttribute('data-slug');
|
||||
@ -317,7 +348,7 @@
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(done, function () { b.textContent = txt; });
|
||||
else done();
|
||||
});
|
||||
document.getElementById('q').addEventListener('input', function (e) { state.q = e.target.value; render(); });
|
||||
document.getElementById('q').addEventListener('input', function (e) { clearFocus(); state.q = e.target.value; render(); });
|
||||
|
||||
load();
|
||||
})();
|
||||
|
||||
@ -92,10 +92,12 @@ webuiRegistryCatalogScan() {
|
||||
local tmp; tmp="$(mktemp)"
|
||||
printf '%s' "$index" | jq \
|
||||
--arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \
|
||||
--arg src_base "$base" --arg src_channel "$(lpReleaseChannel)" \
|
||||
--argjson defined "$defined" --argjson installed "$installed" --argjson icons "$icons_map" '
|
||||
{ generated_at: $now,
|
||||
signed: ($signed=="true"),
|
||||
serial: ($serial|tonumber? // 0),
|
||||
source: { base: $src_base, channel: $src_channel },
|
||||
apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle")
|
||||
| (.applies_when.app // "") as $slug | select($slug != "")
|
||||
| { id,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user