Merge claude/2

This commit is contained in:
librelad 2026-07-03 21:23:14 +01:00
commit 2b54be933f
6 changed files with 152 additions and 13 deletions

View File

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

View File

@ -153,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
@ -227,30 +232,40 @@ 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>
`;
return card;
},
// Populate inline service trigger popups for installed apps

View File

@ -98,6 +98,20 @@ class AppsManager {
return;
}
// 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: 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();
await this.reloadAppsData();
const p = window.location.pathname;
if (p === '/apps' || p.startsWith('/apps/')) {
this.renderApps(window.appsCategory || 'all');
}
return;
}
// First-install welcome modal — only on the very first successful install per app per browser.
if (action === 'install' && status === 'completed' && appName) {
const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
@ -257,6 +271,37 @@ class AppsManager {
// 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.
@ -277,12 +322,9 @@ class AppsManager {
});
}
// Sort installed apps first
filteredApps.sort((a, b) => {
if (a.installed && !b.installed) return -1;
if (!a.installed && b.installed) return 1;
return 0;
});
// 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);
@ -1146,6 +1188,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

View File

@ -141,6 +141,7 @@ Object.assign(TasksManager.prototype, {
'updater_check': 'Check for Updates', 'updater_apply': 'Update',
'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back',
'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix',
'app_add': 'Add App',
'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup'
};

View File

@ -358,6 +358,16 @@ async configUpdate(changes) {
} catch (error) { throw new Error(`Failed to revert hotfix ${id}: ${error.message}`); }
}
// Registry catalog (marketplace) — add an app definition from the signed
// index via `libreportal app add` as a task. Slug charset-guarded before it
// enters the command string (defense in depth; the CLI re-guards).
async appAdd(slug) {
if (!slug || !/^[a-z0-9][a-z0-9_]{0,31}$/.test(slug)) throw new Error('Invalid app slug');
try {
return await this.executeTask('app_add', slug, `libreportal app add ${slug}`, `Add ${slug}`);
} catch (error) { throw new Error(`Failed to add ${slug}: ${error.message}`); }
}
/**
* Create a task object
*/

View File

@ -95,6 +95,11 @@ class TaskRouter {
case 'artifact_revert':
return await this.actions.artifactRevert(params.id);
// Registry catalog (marketplace) — add an app definition from the
// signed index; the App Center's "Available — Add" cards route here.
case 'app_add':
return await this.actions.appAdd(params.slug);
default:
throw new Error(`Unknown action: ${action}`);
}