librelad 897f514735 feat(marketplace): the marketplace server ships as a dev-mode LibrePortal app
containers/marketplace — nginx:alpine app (standard drop-in contract:
config + tagged compose + icon + install hook) whose docroot serves BOTH
halves of the marketplace: the signed catalog channel tree (index.json /
payloads, published into data/<channel>/ by the release tools) and a
self-contained client-rendered browse site over the same file (search,
category chips, trust badges, copyable 'libreportal app add <slug>' —
no third-party assets, no backend, no build step). The official
marketplace is an instance of this app; self-hosting one = installing it
and pointing CFG_RELEASE_BASE_URL at it. Boxes only ever trust the
minisign signature on the catalog, never the website.

New generic gating convention: CFG_<APP>_DEV_ONLY=true keeps an app out
of the App Center grid unless Developer Mode is on (CFG_DEV_MODE, the
same flag the **DEV** config-field filter uses); an installed dev-only
app always stays visible. The marketplace app is the first user.

Cache policy: catalog/channel manifests no-cache; payloads short
revalidating cache (same-id re-publish); version-pinned release
artifacts immutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-03 21:40:33 +01:00

213 lines
9.5 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LibrePortal Marketplace</title>
<link rel="icon" type="image/svg+xml" href="marketplace.svg">
<style>
/* Self-contained by design: no external fonts, scripts or images — the same
no-third-party rule as the LibrePortal WebUI. */
:root {
--bg1:#0b1e3a; --bg2:#0e3a5c; --card:rgba(255,255,255,.06);
--border:rgba(255,255,255,.14); --text:#e8f0fb; --dim:#9db4cc;
--indigo:#6366f1; --indigo-soft:rgba(99,102,241,.28);
--teal:#22d3ee; --teal-soft:rgba(34,211,238,.20);
}
* { box-sizing:border-box; margin:0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color:var(--text); min-height:100vh;
background: radial-gradient(1200px 700px at 15% -10%, #1c4d7a 0%, transparent 60%),
linear-gradient(150deg, var(--bg1), var(--bg2));
}
.wrap { max-width:1080px; margin:0 auto; padding:32px 20px 64px; }
header { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
header img { width:44px; height:44px; }
h1 { font-size:1.7rem; letter-spacing:.3px; }
.tagline { color:var(--dim); margin:4px 0 26px; }
.bar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:18px; }
.bar input {
flex:1; min-width:220px; padding:10px 14px; border-radius:10px;
border:1px solid var(--border); background:var(--card); color:var(--text);
font-size:.95rem; outline:none;
}
.bar input:focus { border-color:var(--indigo); }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:22px; }
.chip {
padding:5px 12px; border-radius:999px; font-size:.8rem; cursor:pointer;
border:1px solid var(--border); background:var(--card); color:var(--dim);
}
.chip.on { background:var(--indigo-soft); border-color:var(--indigo); color:#c7d2fe; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; }
.card {
background:var(--card); border:1px solid var(--border); border-radius:14px;
padding:18px; display:flex; flex-direction:column; gap:10px;
}
.card .top { display:flex; gap:12px; align-items:center; }
.card .icon {
width:44px; height:44px; border-radius:10px; background:rgba(255,255,255,.09);
display:flex; align-items:center; justify-content:center; overflow:hidden;
font-weight:700; font-size:1.2rem; color:#c7d2fe; flex-shrink:0;
}
.card .icon img { width:100%; height:100%; object-fit:contain; }
.card h2 { font-size:1.05rem; }
.tags { display:flex; gap:6px; flex-wrap:wrap; }
.tag { font-size:.72rem; padding:2px 9px; border-radius:999px; border:1px solid var(--border); color:var(--dim); }
.tag.official { background:var(--teal-soft); border-color:rgba(34,211,238,.5); color:#a5f3fc; }
.tag.official::before { content:"✓ "; }
.tag.community { background:rgba(245,158,11,.2); border-color:rgba(245,158,11,.5); color:#fcd34d; }
.desc { color:var(--dim); font-size:.88rem; line-height:1.45; flex:1; }
.addline { display:flex; gap:8px; align-items:center; }
.addline code {
flex:1; font-size:.78rem; padding:8px 10px; border-radius:8px; overflow-x:auto;
background:rgba(0,0,0,.30); border:1px solid var(--border); white-space:nowrap;
}
.addline button {
padding:8px 14px; border-radius:8px; border:none; cursor:pointer;
background:var(--indigo); color:#fff; font-weight:600; font-size:.82rem;
}
.addline button:hover { background:#4f46e5; }
.empty, .meta { color:var(--dim); font-size:.9rem; }
.meta { margin:14px 2px 0; }
footer { margin-top:44px; padding-top:18px; border-top:1px solid var(--border); color:var(--dim); font-size:.85rem; line-height:1.6; }
footer code { background:rgba(0,0,0,.3); padding:1px 6px; border-radius:6px; }
a { color:#93c5fd; }
</style>
</head>
<body>
<div class="wrap">
<header>
<img src="marketplace.svg" alt="">
<h1>LibrePortal Marketplace</h1>
</header>
<p class="tagline">A signed, self-hostable app catalog — browse here, install from your own LibrePortal.</p>
<div class="bar"><input id="q" type="search" placeholder="Search apps…" autocomplete="off"></div>
<div class="chips" id="chips"></div>
<div class="grid" id="grid"></div>
<p class="empty" id="empty" hidden>Nothing published on this channel yet.</p>
<p class="meta" id="meta"></p>
<footer>
<p><strong>Add an app:</strong> copy its command into your LibrePortal box's terminal, or find it
on your App Center's grid as an “Available” card once your box scans this catalog.</p>
<p><strong>Use this marketplace from your box:</strong> set <code>CFG_RELEASE_BASE_URL</code> to this
site's address. <strong>Run your own:</strong> this whole site is the open-source
<code>marketplace</code> app that ships with LibrePortal (Developer&nbsp;Mode) — publish with
<code>make_app.sh</code> and serve the same signed files. Boxes only ever trust the minisign
signature on the catalog, never this website.</p>
</footer>
</div>
<script>
(function () {
'use strict';
var CHANNELS = ['stable', 'edge'];
var state = { apps: [], cat: 'all', q: '' };
var grid = document.getElementById('grid');
var chips = document.getElementById('chips');
var empty = document.getElementById('empty');
var meta = document.getElementById('meta');
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function render() {
var q = state.q.toLowerCase();
var shown = state.apps.filter(function (a) {
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;
});
grid.innerHTML = shown.map(function (a) {
var iconInner = a.icon
? '<img src="' + esc(a.icon) + '" alt="" onerror="this.parentNode.textContent=' + "'" + esc((a.title || '?')[0].toUpperCase()) + "'" + '">'
: esc((a.title || '?')[0].toUpperCase());
var trust = a.trust === 'official'
? '<span class="tag official">Official</span>'
: '<span class="tag community">' + esc(a.trust) + '</span>';
return '<div class="card">' +
'<div class="top"><div class="icon">' + iconInner + '</div><div>' +
'<h2>' + esc(a.title) + '</h2>' +
'<div class="tags"><span class="tag">' + esc(a.category || 'app') + '</span>' + trust +
'<span class="tag">v' + esc(a.version) + '</span></div>' +
'</div></div>' +
'<p class="desc">' + esc(a.long_description || a.description) + '</p>' +
'<div class="addline"><code>libreportal app add ' + esc(a.slug) + '</code>' +
'<button data-slug="' + esc(a.slug) + '">Copy</button></div>' +
'</div>';
}).join('');
empty.hidden = shown.length > 0;
}
function renderChips(cats) {
var all = ['all'].concat(cats);
chips.innerHTML = all.map(function (c) {
return '<span class="chip' + (c === state.cat ? ' on' : '') + '" data-cat="' + esc(c) + '">' + esc(c) + '</span>';
}).join('');
}
function load(i) {
i = i || 0;
if (i >= CHANNELS.length) { empty.hidden = false; return; }
var ch = CHANNELS[i];
fetch(ch + '/index.json', { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (idx) {
state.apps = (idx.artifacts || []).filter(function (a) {
return a && a.type === 'app' && a.payload && a.payload.kind === 'bundle' &&
a.applies_when && a.applies_when.app;
}).map(function (a) {
var m = a.meta || {};
return {
slug: a.applies_when.app, title: a.title || a.applies_when.app,
description: m.description || a.why || '',
long_description: m.long_description || '',
category: (m.category || '').toLowerCase(),
trust: a.trust || 'official', version: a.version || 1,
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
};
});
var cats = [];
state.apps.forEach(function (a) { if (a.category && cats.indexOf(a.category) < 0) cats.push(a.category); });
renderChips(cats.sort());
meta.textContent = state.apps.length + ' app(s) · channel: ' + ch + ' · catalog serial ' + (idx.index_serial || '?') +
' · published ' + (idx.generated_at || '?');
render();
})
.catch(function () { load(i + 1); });
}
chips.addEventListener('click', function (e) {
var c = e.target.getAttribute && e.target.getAttribute('data-cat');
if (!c) return;
state.cat = c;
renderChips(state.apps.reduce(function (acc, a) {
if (a.category && acc.indexOf(a.category) < 0) acc.push(a.category);
return acc;
}, []).sort());
render();
});
grid.addEventListener('click', function (e) {
var slug = e.target.getAttribute && e.target.getAttribute('data-slug');
if (!slug) return;
var txt = 'libreportal app add ' + slug;
(navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject())
.then(function () { e.target.textContent = 'Copied!'; })
.catch(function () { e.target.textContent = txt; })
.finally ? null : null;
setTimeout(function () { e.target.textContent = 'Copy'; }, 1600);
});
document.getElementById('q').addEventListener('input', function (e) {
state.q = e.target.value; render();
});
load();
})();
</script>
</body>
</html>