Merge claude/2

This commit is contained in:
librelad 2026-07-04 22:04:46 +01:00
commit 8e08e85db3
5 changed files with 117 additions and 14 deletions

View File

@ -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;

View File

@ -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}

View File

@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async installApp(appName) {
const installedApp = (window.apps || []).find(a =>
(a.command || '').endsWith(` ${appName}`) || a.name === appName

View File

@ -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 isnt 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();
})();

View File

@ -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,