Merge claude/2
This commit is contained in:
commit
2b54be933f
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
};
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user