Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
215 lines
9.6 KiB
HTML
215 lines
9.6 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;
|
|
scrollbar-width:none;
|
|
}
|
|
.addline code::-webkit-scrollbar { display:none; }
|
|
.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 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|