Merge claude/2

This commit is contained in:
librelad 2026-07-06 22:29:46 +01:00
commit 06723e0995
5 changed files with 110 additions and 18 deletions

View File

@ -6,6 +6,7 @@
# in priority order 1..9. The official catalog (from the Release Host) is always # in priority order 1..9. The official catalog (from the Release Host) is always
# source #1 and is NOT listed here. Third-party catalogs are UNVERIFIED — you are # source #1 and is NOT listed here. Third-party catalogs are UNVERIFIED — you are
# trusting that host. Edit these from the App Center's Catalog Sources block. # trusting that host. Edit these from the App Center's Catalog Sources block.
CFG_CATALOG_OFFICIAL_ENABLED=true # Official Catalog - Include the official LibrePortal catalog (source #1) in the App Center. Disabling only hides its apps from browsing; system updates/hotfixes still use the official release host. [true:On|false:Off] **ADVANCED**
CFG_CATALOG_1= # Catalog 1 - Extra catalog base URL, e.g. https://catalog.example.org CFG_CATALOG_1= # Catalog 1 - Extra catalog base URL, e.g. https://catalog.example.org
CFG_CATALOG_2= # Catalog 2 - Extra catalog base URL CFG_CATALOG_2= # Catalog 2 - Extra catalog base URL
CFG_CATALOG_3= # Catalog 3 - Extra catalog base URL CFG_CATALOG_3= # Catalog 3 - Extra catalog base URL

View File

@ -25,15 +25,48 @@ class CatalogManager {
</div>`; </div>`;
} }
// The pinned official row (source #1). URL comes from the generated catalog
// data; the hidden CFG_CATALOG_OFFICIAL_ENABLED input carries its on/off state
// so it saves with the rest on "Save Config".
_officialRow(base, enabled) {
const host = base ? base.replace(/^https?:\/\//, '').replace(/\/$/, '') : 'official catalog';
const off = enabled === false;
return `
<div class="domain-building-block" data-catalog-official style="${off ? 'opacity:.55;' : ''}">
<div class="domain-header" style="align-items:center; gap:.6rem;">
<div style="flex:1; display:flex; align-items:center; gap:.6rem; min-width:0;">
<span style="display:inline-flex; align-items:center; padding:.15rem .55rem; border-radius:999px; background:rgba(46,204,113,.15); color:#2ecc71; font-weight:600; font-size:.8rem; white-space:nowrap;"> Official</span>
<span style="opacity:.85; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${this._esc(host)}<span data-official-hint>${off ? ' <em style="opacity:.7;">(hidden from browse)</em>' : ''}</span>
</span>
</div>
<input type="hidden" name="CFG_CATALOG_OFFICIAL_ENABLED" id="config-CFG_CATALOG_OFFICIAL_ENABLED" value="${off ? 'false' : 'true'}">
<span data-official-action>${window.officialCatalogActionHTML(!off)}</span>
</div>
</div>`;
}
async _officialBase() {
try {
const r = await fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' });
if (r.ok) { const j = await r.json(); return (j.source && j.source.base) || ''; }
} catch (_) {}
return '';
}
async renderCatalogsSection(configItems, displaySubcategory, subcategoryDescription) { async renderCatalogsSection(configItems, displaySubcategory, subcategoryDescription) {
const keys = configItems.map(i => i.key).filter(k => k.startsWith('CFG_CATALOG_')); const keys = configItems.map(i => i.key).filter(k => k.startsWith('CFG_CATALOG_') && k !== 'CFG_CATALOG_OFFICIAL_ENABLED');
const withContent = keys const withContent = keys
.filter(k => ((configItems.find(i => i.key === k) || {}).value || '').trim() !== '') .filter(k => ((configItems.find(i => i.key === k) || {}).value || '').trim() !== '')
.sort((a, b) => this._num(a) - this._num(b)); .sort((a, b) => this._num(a) - this._num(b));
const highest = withContent.length ? Math.max(...withContent.map(k => this._num(k))) : 0; const highest = withContent.length ? Math.max(...withContent.map(k => this._num(k))) : 0;
const isMax = withContent.length >= 9; const isMax = withContent.length >= 9;
let blocks = ''; const officialItem = configItems.find(i => i.key === 'CFG_CATALOG_OFFICIAL_ENABLED') || {};
const officialEnabled = String(officialItem.value == null ? 'true' : officialItem.value) !== 'false';
const officialBase = await this._officialBase();
let blocks = this._officialRow(officialBase, officialEnabled);
withContent.forEach(key => { withContent.forEach(key => {
const value = (configItems.find(i => i.key === key) || {}).value || ''; const value = (configItems.find(i => i.key === key) || {}).value || '';
blocks += this._block(key, value, this._num(key) === highest); blocks += this._block(key, value, this._num(key) === highest);
@ -53,7 +86,7 @@ class CatalogManager {
<div class="traefik-warning-content"> <div class="traefik-warning-content">
<span class="traefik-warning-icon">🛈</span> <span class="traefik-warning-icon">🛈</span>
<div class="traefik-warning-text"> <div class="traefik-warning-text">
<strong>The official catalog is always included.</strong> Add third-party catalog URLs to browse more apps third-party sources are <em>unverified</em> (you are trusting that host). Each is served with a signed <code>/&lt;channel&gt;/index.json</code>. <strong>The official catalog is always included.</strong> Add third-party catalog URLs to browse more apps third-party sources are <em>unverified</em>.
</div> </div>
</div> </div>
</div> </div>
@ -150,3 +183,44 @@ window.validateCatalogFormat = function (input) {
input.title = ok ? '' : 'Catalog must be an http(s):// URL'; input.title = ok ? '' : 'Catalog must be an http(s):// URL';
return ok; return ok;
}; };
// --- official catalog (source #1): shown pinned, removable only with a warning ---
window.officialCatalogActionHTML = function (enabled) {
return enabled
? `<button type="button" class="delete-domain-btn" title="Remove the official catalog from browsing" onclick="window.deleteOfficialCatalog()"><span class="delete-icon">×</span></button>`
: `<button type="button" class="btn btn-secondary" style="padding:.3rem .8rem;" onclick="window.enableOfficialCatalog()">Re-enable</button>`;
};
window.deleteOfficialCatalog = function () {
const apply = () => window._setOfficialCatalog(false);
if (window.showConfirmation) {
window.showConfirmation(
'Remove the official catalog?',
'This hides the official LibrePortal catalogs apps from the App Center. Your system updates and hotfixes are NOT affected — they always use the official release host. You can re-enable it any time. Click Save Config to apply.',
apply, 'Remove from browsing', 'Cancel', 'danger', true,
'I understand this only hides official apps from browsing, not system updates'
);
} else if (window.confirm('Remove the official catalog from browsing?')) {
apply();
}
};
window.enableOfficialCatalog = function () { window._setOfficialCatalog(true); };
window._setOfficialCatalog = function (enabled) {
const input = document.getElementById('config-CFG_CATALOG_OFFICIAL_ENABLED');
if (input) input.value = enabled ? 'true' : 'false';
const row = document.querySelector('[data-catalog-official]');
if (row) {
row.style.opacity = enabled ? '' : '.55';
const action = row.querySelector('[data-official-action]');
if (action) action.innerHTML = window.officialCatalogActionHTML(enabled);
const hint = row.querySelector('[data-official-hint]');
if (hint) hint.innerHTML = enabled ? '' : ' <em style="opacity:.7;">(hidden from browse)</em>';
}
if (window.notificationSystem) {
window.notificationSystem.info(enabled
? 'Official catalog will be re-enabled on Save.'
: 'Official catalog will be hidden on Save.');
}
};

View File

@ -265,6 +265,20 @@
grid-auto-rows: minmax(min-content, max-content); grid-auto-rows: minmax(min-content, max-content);
} }
/* Boxed content panel for a config section's body (the area after the divider,
before any action buttons). A darker inset inside the section card so the
options read as their own content section matches the boxed look used
elsewhere. Applies to regular option grids and the block editors
(domains/catalog/whitelist). Scoped to the main config editor (not app-config). */
.config-category:not(.app-config) .config-fields,
.config-category:not(.app-config) .domain-building-blocks,
.config-category:not(.app-config) .whitelist-building-blocks {
background: rgba(0, 0, 0, 0.20);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 18px;
}
/* Responsive column counts colocated here (and not in style.css) /* Responsive column counts colocated here (and not in style.css)
because the base 3-column rule above loads later than style.css, because the base 3-column rule above loads later than style.css,
so the unscoped base would otherwise win the cascade against the so the unscoped base would otherwise win the cascade against the

View File

@ -31,8 +31,8 @@ class ConfirmationDialog {
} }
show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false) { show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false, checkboxText = 'I understand I will lose all my data and it cannot be undone') {
this.callback = onConfirm; this.callback = onConfirm;
// Build dialog content // Build dialog content
@ -50,7 +50,7 @@ class ConfirmationDialog {
<div class="confirmation-checkbox"> <div class="confirmation-checkbox">
<label> <label>
<input type="checkbox" id="dataLossCheckbox" onchange="window.confirmationDialog.updateConfirmButton(this.checked)"> <input type="checkbox" id="dataLossCheckbox" onchange="window.confirmationDialog.updateConfirmButton(this.checked)">
<span>I understand I will lose all my data and it cannot be undone</span> <span>${this.escapeHtml(checkboxText)}</span>
</label> </label>
</div> </div>
` : ''} ` : ''}
@ -142,12 +142,12 @@ function initConfirmationDialog() {
// initConfirmationDialog() will be called centrally // initConfirmationDialog() will be called centrally
// Global function // Global function
window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox) => { window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText) => {
// Ensure dialog is initialized // Ensure dialog is initialized
initConfirmationDialog(); initConfirmationDialog();
if (confirmationDialog) { if (confirmationDialog) {
confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox); confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText);
} else { } else {
// Fallback to native confirm // Fallback to native confirm
if (confirm(message)) { if (confirm(message)) {

View File

@ -40,26 +40,29 @@ catalogExtraSourcesJson() {
printf '%s' "$arr" printf '%s' "$arr"
} }
# The full ordered source list as a JSON array. Each: {idx,name,base,channel,official,trust}. # The full ordered source list as a JSON array. Each:
# {idx,name,base,channel,official,trust,enabled}. The official source can be
# switched off for browsing via CFG_CATALOG_OFFICIAL_ENABLED (default on) —
# extras are always enabled (an empty slot is simply absent).
catalogSourcesJson() { catalogSourcesJson() {
local off_base off_channel extras local off_base off_channel extras off_enabled
off_base="$(lpReleaseBaseUrl)"; off_base="${off_base%/}" off_base="$(lpReleaseBaseUrl)"; off_base="${off_base%/}"
off_channel="$(lpReleaseChannel)" off_channel="$(lpReleaseChannel)"
off_enabled="true"; [[ "${CFG_CATALOG_OFFICIAL_ENABLED:-true}" == "false" ]] && off_enabled="false"
extras="$(catalogExtraSourcesJson)" extras="$(catalogExtraSourcesJson)"
jq -cn --arg base "$off_base" --arg channel "$off_channel" --argjson extras "$extras" ' jq -cn --arg base "$off_base" --arg channel "$off_channel" --argjson oe "$off_enabled" --argjson extras "$extras" '
[ { idx: 1, name: "LibrePortal Official", base: $base, channel: $channel, [ { idx: 1, name: "LibrePortal Official", base: $base, channel: $channel,
official: true, trust: "official" } ] official: true, trust: "official", enabled: $oe } ]
+ ( $extras | map( . + { official: false, trust: "community" } ) )' + ( $extras | map( . + { official: false, trust: "community", enabled: true } ) )'
} }
# One source per line (compact JSON), priority order — for `while read`. Every # One ENABLED source per line (compact JSON), priority order — for `while read`.
# configured source is active (empty slots are skipped upstream). catalogEnabledSources() { catalogSourcesJson | jq -c '.[] | select(.enabled)'; }
catalogEnabledSources() { catalogSourcesJson | jq -c '.[]'; }
# Human-readable listing for the CLI. # Human-readable listing for the CLI.
catalogSourceList() { catalogSourceList() {
catalogSourcesJson | jq -r '.[] | catalogSourcesJson | jq -r '.[] |
"[\(.idx)] \(.name) — \(.base)/\(.channel) (\(if .official then "official ✓" else "unverified" end))"' "[\(.idx)] \(.name) — \(.base)/\(.channel) (\(if .official then "official ✓" else "unverified" end)\(if .enabled then "" else ", off" end))"'
} }
# Fetch a THIRD-PARTY catalog index. Deliberately NOT verified against the # Fetch a THIRD-PARTY catalog index. Deliberately NOT verified against the