feat(marketplace/site): How it works, Submit an app + Submissions views
Turns the site into a small multi-view app. Three pinned nav buttons above the search (App Center pin style) switch the main pane: - How it works: the former footer text, expanded into cards (add an app, point your box here, how trust works, run your own). - Submit an app: the PR-based flow (fork → drop-in app folder → PR → reviewed + signed → published with a community badge + your name). No uploads/accounts. - Submissions: renders a static submissions.json (operator-published, like the catalog) — author names, status badges (pending/review/merged), PR links, a pending-count badge on the pin, graceful empty/loading states. Category/search return to browse; #<slug> deep-link still focuses one app. REPO_URL is overridable via <meta name=lp-repo>. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
06164fedd4
commit
01cd6c3f9d
@ -172,6 +172,64 @@
|
||||
.footnote { margin: 4px 22px 40px; font-size: 0.85rem; line-height: 1.6; }
|
||||
.footnote code { background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 6px; }
|
||||
a { color: #93c5fd; }
|
||||
|
||||
/* Sidebar nav pins (How it works / Submit / Submissions) — App Center pin
|
||||
style: full-width, bottom-border, icon + label, hover/active tint. */
|
||||
.side-nav { border-bottom: 1px solid var(--sidebar-border); }
|
||||
.side-pin {
|
||||
display: flex; align-items: center; gap: 10px; padding: 15px 20px; cursor: pointer;
|
||||
color: var(--sidebar-text); font-weight: 600; font-size: 14px;
|
||||
border-bottom: 1px solid var(--sidebar-border); transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.side-nav .side-pin:last-child { border-bottom: none; }
|
||||
.side-pin svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
.side-pin:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.side-pin.active { background: rgba(var(--accent-rgb),0.16); color: #fff; }
|
||||
.side-pin-badge {
|
||||
margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
|
||||
background: rgba(var(--status-warning-rgb),0.9); color: #1a1206; font-size: 0.72rem; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* Info + submissions views (rendered into #main). */
|
||||
.doc { max-width: 820px; margin: 22px; }
|
||||
.doc h1 { font-size: 1.5rem; margin-bottom: 6px; }
|
||||
.doc .lead { color: var(--text-secondary); margin-bottom: 22px; line-height: 1.5; }
|
||||
.doc-card {
|
||||
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px;
|
||||
padding: 18px 20px; margin-bottom: 16px; box-shadow: var(--card-shadow);
|
||||
}
|
||||
.doc-card h2 { font-size: 1.05rem; margin-bottom: 8px; }
|
||||
.doc-card p, .doc-card li { color: var(--text-secondary); line-height: 1.55; font-size: 0.92rem; }
|
||||
.doc-card ol, .doc-card ul { margin: 6px 0 0 20px; }
|
||||
.doc-card li { margin-bottom: 6px; }
|
||||
.doc-card code { background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 6px; font-size: 0.86em; }
|
||||
.doc-steps { counter-reset: step; list-style: none; margin-left: 0 !important; }
|
||||
.doc-steps li { position: relative; padding-left: 38px; margin-bottom: 12px; }
|
||||
.doc-steps li::before {
|
||||
counter-increment: step; content: counter(step);
|
||||
position: absolute; left: 0; top: 0; width: 26px; height: 26px; border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb),0.2); border: 1px solid rgba(var(--accent-rgb),0.5); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem;
|
||||
}
|
||||
.doc-cta { display: inline-block; margin-top: 4px; padding: 9px 16px; border-radius: 8px; background: #6366f1; color: #fff !important; font-weight: 600; font-size: 0.9rem; text-decoration: none; }
|
||||
.doc-cta:hover { background: #4f46e5; }
|
||||
|
||||
.sub-list { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
|
||||
.sub-row {
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 18px;
|
||||
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px; box-shadow: var(--card-shadow);
|
||||
}
|
||||
.sub-icon { width: 40px; height: 40px; border-radius: 10px; background: rgba(var(--text-rgb),0.08); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #c7d2fe; flex-shrink: 0; }
|
||||
.sub-main { flex: 1; min-width: 0; }
|
||||
.sub-title { font-weight: 600; }
|
||||
.sub-meta { color: var(--text-muted); font-size: 0.85rem; margin-top: 2px; }
|
||||
.sub-meta .who { color: var(--text-secondary); }
|
||||
.sub-status { font-size: 0.75rem; padding: 3px 10px; border-radius: 999px; border: 1px solid; white-space: nowrap; }
|
||||
.sub-status.pending { background: rgba(var(--status-warning-rgb),0.2); color: #fcd34d; border-color: rgba(var(--status-warning-rgb),0.5); }
|
||||
.sub-status.merged { background: rgba(var(--status-success-rgb),0.28); color: #86efac; border-color: rgba(var(--status-success-rgb),0.6); }
|
||||
.sub-status.review { background: rgba(var(--accent-rgb),0.18); color: #7dd3fc; border-color: rgba(var(--accent-rgb),0.45); }
|
||||
.sub-link { color: #93c5fd; font-size: 0.85rem; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -185,6 +243,22 @@
|
||||
<div class="apps-layout">
|
||||
<div class="sidebar-container">
|
||||
<div class="sidebar">
|
||||
<!-- Pinned nav above the search — like the App Center's Overview pin. -->
|
||||
<div class="side-nav" id="sidenav">
|
||||
<div class="side-pin" data-view="howto" role="button" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
<span>How it works</span>
|
||||
</div>
|
||||
<div class="side-pin" data-view="submit" role="button" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span>Submit an app</span>
|
||||
</div>
|
||||
<div class="side-pin" data-view="submissions" role="button" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span>Submissions</span>
|
||||
<span class="side-pin-badge" id="subs-badge" hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apps-search">
|
||||
<svg class="apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input id="q" type="search" placeholder="Search apps…" autocomplete="off" spellcheck="false">
|
||||
@ -193,35 +267,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="status-strip" id="status"></div>
|
||||
<div class="focusbar" id="focusbar" hidden></div>
|
||||
<div class="apps-section" id="grid"></div>
|
||||
<div class="empty" id="empty" hidden>Nothing published on this channel yet.</div>
|
||||
<p class="footnote">
|
||||
<strong>Add an app:</strong> click a card's <em>Add</em> to copy its command, then run it on your box —
|
||||
or it shows up in your App Center grid as an “Available” card once your box scans this catalog.
|
||||
<strong>Use this marketplace:</strong> point your box's <code>CFG_RELEASE_BASE_URL</code> at this site.
|
||||
<strong>Run your own:</strong> this whole site is the open-source <code>marketplace</code> app (Developer Mode);
|
||||
publish with <code>make_app.sh</code>. Boxes trust only the catalog's signature, never this website.
|
||||
</p>
|
||||
</div>
|
||||
<div class="main-content" id="main"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var CHANNELS = ['stable', 'edge'];
|
||||
// The catalog repository submissions are opened against. Operators running
|
||||
// their own marketplace set this to their repo (a <meta name="lp-repo"> wins).
|
||||
var REPO_URL = (document.querySelector('meta[name="lp-repo"]') || {}).content || 'https://github.com/librelad/LibrePortal';
|
||||
|
||||
function readFocus() {
|
||||
var h = (window.location.hash || '').replace(/^#/, '');
|
||||
return /^[a-z0-9][a-z0-9_]{0,31}$/.test(h) ? h : '';
|
||||
}
|
||||
var state = { apps: [], cat: 'all', q: '', focus: readFocus() };
|
||||
var grid = document.getElementById('grid');
|
||||
var state = { view: 'browse', apps: [], cat: 'all', q: '', focus: readFocus(),
|
||||
idx: null, channel: 'stable', signed: false, submissions: null };
|
||||
var main = document.getElementById('main');
|
||||
var cats = document.getElementById('cats');
|
||||
var empty = document.getElementById('empty');
|
||||
var status = document.getElementById('status');
|
||||
var topchip = document.getElementById('topchip');
|
||||
var sidenav = document.getElementById('sidenav');
|
||||
var subsBadge = document.getElementById('subs-badge');
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
@ -229,25 +296,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- sidebar ----
|
||||
function counts() {
|
||||
var by = {};
|
||||
state.apps.forEach(function (a) { if (a.category) by[a.category] = (by[a.category] || 0) + 1; });
|
||||
return by;
|
||||
}
|
||||
|
||||
// Inline glyphs for the special rows (currentColor, so they follow the row's
|
||||
// text colour — the App Center renders All/Recommended inline for this reason;
|
||||
// the category .svg files carry their own fixed blue, so those stay as <img>).
|
||||
var GRID_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
|
||||
var STAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
|
||||
function catRow(id, label, glyph) {
|
||||
return '<div class="category' + (state.cat === id ? ' active' : '') + '" data-cat="' + esc(id) + '">' +
|
||||
glyph + '<span>' + esc(label) + '</span></div>';
|
||||
}
|
||||
function catImg(id) {
|
||||
return '<img src="categories/' + esc(id) + '.svg" alt="" onerror="this.src=\'categories/misc.svg\'">';
|
||||
var active = (state.view === 'browse' && state.cat === id);
|
||||
return '<div class="category' + (active ? ' active' : '') + '" data-cat="' + esc(id) + '">' + glyph + '<span>' + esc(label) + '</span></div>';
|
||||
}
|
||||
function catImg(id) { return '<img src="categories/' + esc(id) + '.svg" alt="" onerror="this.src=\'categories/misc.svg\'">'; }
|
||||
function renderCats() {
|
||||
var list = Object.keys(counts()).sort();
|
||||
var rows = [catRow('all', 'All', GRID_SVG)];
|
||||
@ -255,41 +316,42 @@
|
||||
list.forEach(function (c) { rows.push(catRow(c, c, catImg(c))); });
|
||||
cats.innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
function card(a) {
|
||||
var iconInner = a.icon
|
||||
? '<img src="' + esc(a.icon) + '" alt="" onerror="this.style.display=\'none\'">'
|
||||
: '<img src="marketplace.svg" alt="">';
|
||||
var trust = a.trust === 'official'
|
||||
? '<span class="app-tag trust-badge">Official</span>'
|
||||
: '<span class="app-tag community-badge">' + esc(a.trust) + '</span>';
|
||||
var stateTag = a.installed
|
||||
? '<span class="app-tag installed-tag">Installed</span>'
|
||||
: '<span class="app-tag available-tag">Available</span>';
|
||||
var descTag = a.description
|
||||
? '<span class="app-tag description-tag">' + esc(a.description) + '</span>' : '';
|
||||
var btn = a.installed
|
||||
? '<button class="installed-btn" disabled>Installed</button>'
|
||||
: '<button data-slug="' + esc(a.slug) + '">Add</button>';
|
||||
return '<div class="app-card">' +
|
||||
'<div class="app-card-top">' +
|
||||
'<div class="app-card-icon">' + iconInner + '</div>' +
|
||||
'<div class="app-card-content">' +
|
||||
'<div class="app-card-title">' + esc(a.title) + '</div>' +
|
||||
'<div class="app-card-tags">' + descTag +
|
||||
'<span class="app-tag category-tag">' + esc(a.category || 'app') + '</span>' +
|
||||
stateTag + trust +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(a.long_description ? '<div class="app-card-long-description">' + esc(a.long_description) + '</div>' : '') +
|
||||
'<div class="app-card-actions">' + btn + '</div>' +
|
||||
'</div>';
|
||||
function renderNav() {
|
||||
Array.prototype.forEach.call(sidenav.querySelectorAll('.side-pin'), function (p) {
|
||||
p.classList.toggle('active', state.view === p.getAttribute('data-view'));
|
||||
});
|
||||
var pending = (state.submissions || []).filter(function (s) { return s.status === 'pending'; }).length;
|
||||
if (pending) { subsBadge.textContent = pending; subsBadge.hidden = false; } else { subsBadge.hidden = true; }
|
||||
}
|
||||
|
||||
function render() {
|
||||
var q = state.q.toLowerCase();
|
||||
var focus = state.focus; // #<slug> deep-link → show just that app
|
||||
// ---- browse view ----
|
||||
function card(a) {
|
||||
var iconInner = a.icon ? '<img src="' + esc(a.icon) + '" alt="" onerror="this.style.display=\'none\'">' : '<img src="marketplace.svg" alt="">';
|
||||
var trust = a.trust === 'official' ? '<span class="app-tag trust-badge">Official</span>' : '<span class="app-tag community-badge">' + esc(a.trust) + '</span>';
|
||||
var stateTag = a.installed ? '<span class="app-tag installed-tag">Installed</span>' : '<span class="app-tag available-tag">Available</span>';
|
||||
var descTag = a.description ? '<span class="app-tag description-tag">' + esc(a.description) + '</span>' : '';
|
||||
var btn = a.installed ? '<button class="installed-btn" disabled>Installed</button>' : '<button data-slug="' + esc(a.slug) + '">Add</button>';
|
||||
return '<div class="app-card"><div class="app-card-top"><div class="app-card-icon">' + iconInner + '</div>' +
|
||||
'<div class="app-card-content"><div class="app-card-title">' + esc(a.title) + '</div>' +
|
||||
'<div class="app-card-tags">' + descTag + '<span class="app-tag category-tag">' + esc(a.category || 'app') + '</span>' + stateTag + trust + '</div></div></div>' +
|
||||
(a.long_description ? '<div class="app-card-long-description">' + esc(a.long_description) + '</div>' : '') +
|
||||
'<div class="app-card-actions">' + btn + '</div></div>';
|
||||
}
|
||||
function statusHtml() {
|
||||
if (!state.idx) return '<span class="warn">No catalog published on this host yet.</span>';
|
||||
var avail = state.apps.filter(function (a) { return !a.installed; }).length;
|
||||
return (state.signed ? '<span class="ok">✓ signed</span>' : '<span class="warn">unsigned</span>') +
|
||||
'<span>' + avail + ' available</span><span>' + state.apps.length + ' apps</span>' +
|
||||
'<span>channel ' + esc(state.channel) + '</span>' +
|
||||
'<span>serial ' + esc(String(state.idx.index_serial != null ? state.idx.index_serial : '?')) + '</span>' +
|
||||
'<span>published ' + esc(state.idx.generated_at || '?') + '</span>';
|
||||
}
|
||||
function renderBrowse() {
|
||||
main.innerHTML = '<div class="status-strip" id="status"></div><div class="focusbar" id="focusbar" hidden></div>' +
|
||||
'<div class="apps-section" id="grid"></div><div class="empty" id="empty" hidden></div>';
|
||||
document.getElementById('status').innerHTML = statusHtml();
|
||||
var grid = document.getElementById('grid'), empty = document.getElementById('empty'), fb = document.getElementById('focusbar');
|
||||
var q = state.q.toLowerCase(), focus = state.focus;
|
||||
var shown = state.apps.filter(function (a) {
|
||||
if (focus) return a.slug === focus;
|
||||
if (state.cat === '__featured__') { if (!a.featured) return false; }
|
||||
@ -297,47 +359,94 @@
|
||||
if (q && (a.title + ' ' + a.description + ' ' + a.long_description + ' ' + a.slug).toLowerCase().indexOf(q) < 0) return false;
|
||||
return true;
|
||||
});
|
||||
var fb = document.getElementById('focusbar');
|
||||
if (focus) {
|
||||
var one = state.apps.filter(function (a) { return a.slug === focus; })[0];
|
||||
fb.innerHTML = '<span>Linked from the marketplace: <strong>' + esc(one ? one.title : focus) + '</strong></span>' +
|
||||
'<button id="showall">← Show all apps</button>';
|
||||
fb.innerHTML = '<span>Linked from the marketplace: <strong>' + esc(one ? one.title : focus) + '</strong></span><button id="showall">← Show all apps</button>';
|
||||
fb.hidden = false;
|
||||
} else { fb.hidden = true; fb.innerHTML = ''; }
|
||||
}
|
||||
grid.innerHTML = shown.map(card).join('');
|
||||
grid.style.display = shown.length ? '' : 'none';
|
||||
empty.hidden = shown.length > 0;
|
||||
if (focus && !shown.length) empty.textContent = 'That app isn’t on this channel.';
|
||||
else if (state.q || state.cat !== 'all') empty.textContent = 'Nothing matches your filter.';
|
||||
empty.textContent = focus ? 'That app isn’t on this channel.' : ((state.q || state.cat !== 'all') ? 'Nothing matches your filter.' : 'Nothing published on this channel yet.');
|
||||
}
|
||||
|
||||
// ---- how it works ----
|
||||
function howtoHtml() {
|
||||
return '<div class="doc"><h1>How it works</h1>' +
|
||||
'<p class="lead">This is a signed catalog of apps you can add to your LibrePortal box. Browsing happens here; adding happens on your box.</p>' +
|
||||
'<div class="doc-card"><h2>Add an app</h2><p>Click a card’s <strong>Add</strong> to copy its <code>libreportal app add <slug></code> command and run it on your box. Or, once your box has scanned this catalog, the app shows up in your App Center grid as an “Available” card you can add with one click.</p></div>' +
|
||||
'<div class="doc-card"><h2>Point your box at this marketplace</h2><p>Set <code>CFG_RELEASE_BASE_URL</code> to this site’s address (Config → Developer Mode). Your box fetches and verifies this catalog on its next scan, and its App Center starts offering these apps.</p></div>' +
|
||||
'<div class="doc-card"><h2>How trust works</h2><p>The catalog is signed with <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener noreferrer">minisign</a>. Your box verifies that signature against its own root-owned key <em>before</em> adding anything — a website, even this one, can never forge or tamper with an app. An unsigned catalog is browseable, but adds are refused.</p></div>' +
|
||||
'<div class="doc-card"><h2>Run your own marketplace</h2><p>This entire site is the open-source <code>marketplace</code> app that ships with LibrePortal (enable Developer Mode to install it). Publish apps with <code>make_app.sh</code>, drop the output into its data directory, and point boxes at your <code>CFG_RELEASE_BASE_URL</code>. Boxes trust only the catalog’s signature, never the website.</p></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ---- submit an app ----
|
||||
function submitHtml() {
|
||||
return '<div class="doc"><h1>Submit an app</h1>' +
|
||||
'<p class="lead">The catalog is a signed, git-published index — submissions are pull requests, not uploads. No accounts, no upload server; that’s deliberate.</p>' +
|
||||
'<div class="doc-card"><h2>The flow</h2><ol class="doc-steps">' +
|
||||
'<li>Fork the catalog repository and add your app as a <code>containers/<app>/</code> folder — the standard drop-in shape: a <code>.config</code>, a templated <code>docker-compose.yml</code>, an icon, and optional <code>tools/</code> / <code>scripts/</code>.</li>' +
|
||||
'<li>Open a pull request — one app per PR.</li>' +
|
||||
'<li>A maintainer reviews it (especially any <code>tools/*.sh</code> host scripts) for safety and quality.</li>' +
|
||||
'<li>Once approved it’s signed with the catalog key and published. It appears here — and in every box that scans this catalog — with a <strong>community</strong> badge and your name as the publisher.</li>' +
|
||||
'</ol></div>' +
|
||||
'<div class="doc-card"><h2>Why a PR and not an upload?</h2><p>App definitions can ship host-side scripts that run near-root on people’s boxes. A reviewed, signed pull request keeps that trustworthy without accounts, moderation queues, or a server that could be coerced — the same reason boxes verify signatures instead of trusting this site.</p><a class="doc-cta" href="' + esc(REPO_URL) + '" target="_blank" rel="noopener noreferrer">Open the catalog repo ↗</a></div>' +
|
||||
'<div class="doc-card"><h2>Status</h2><p>Community submissions are opening progressively — first-party apps are curated for now. Open submissions appear under <strong>Submissions</strong>.</p></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ---- submissions ----
|
||||
function subRow(s) {
|
||||
var st = (s.status === 'merged') ? 'merged' : (s.status === 'review' ? 'review' : 'pending');
|
||||
var label = st === 'merged' ? 'Published' : (st === 'review' ? 'In review' : 'Pending review');
|
||||
var initial = esc((s.title || s.app || '?').charAt(0).toUpperCase());
|
||||
var meta = [];
|
||||
if (s.author) meta.push('by <span class="who">' + esc(s.author) + '</span>');
|
||||
if (s.category) meta.push(esc(s.category));
|
||||
if (s.opened) meta.push('opened ' + esc(s.opened));
|
||||
return '<div class="sub-row"><div class="sub-icon">' + initial + '</div>' +
|
||||
'<div class="sub-main"><div class="sub-title">' + esc(s.title || s.app) + '</div>' +
|
||||
'<div class="sub-meta">' + meta.join(' · ') + '</div></div>' +
|
||||
'<span class="sub-status ' + st + '">' + label + '</span>' +
|
||||
(s.url ? '<a class="sub-link" href="' + esc(s.url) + '" target="_blank" rel="noopener noreferrer">View PR ↗</a>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
function submissionsHtml() {
|
||||
var body;
|
||||
if (state.submissions === null) body = '<div class="empty">Loading submissions…</div>';
|
||||
else if (!state.submissions.length) body = '<div class="empty">No open submissions right now — be the first. See <strong>Submit an app</strong>.</div>';
|
||||
else body = '<div class="sub-list">' + state.submissions.map(subRow).join('') + '</div>';
|
||||
return '<div class="doc"><h1>Submissions</h1>' +
|
||||
'<p class="lead">Apps proposed by the community — reviewed and signed before they’re published. Each links to its pull request.</p>' +
|
||||
body + '</div>';
|
||||
}
|
||||
|
||||
// ---- dispatch ----
|
||||
function render() {
|
||||
renderNav(); renderCats();
|
||||
if (state.view === 'howto') main.innerHTML = howtoHtml();
|
||||
else if (state.view === 'submit') main.innerHTML = submitHtml();
|
||||
else if (state.view === 'submissions') main.innerHTML = submissionsHtml();
|
||||
else renderBrowse();
|
||||
}
|
||||
function go(view) { state.view = view; window.scrollTo(0, 0); render(); }
|
||||
function toBrowse() { state.view = 'browse'; }
|
||||
function clearFocus() {
|
||||
if (!state.focus) return;
|
||||
state.focus = '';
|
||||
if (window.location.hash) { try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) { window.location.hash = ''; } }
|
||||
}
|
||||
|
||||
function setStatus(idx, ch, signed) {
|
||||
var avail = state.apps.filter(function (a) { return !a.installed; }).length;
|
||||
// The website can't verify signatures (boxes do) — only report whether a
|
||||
// detached signature is published alongside the catalog.
|
||||
status.innerHTML =
|
||||
(signed ? '<span class="ok">✓ signed</span>' : '<span class="warn">unsigned</span>') +
|
||||
'<span>' + avail + ' available</span>' +
|
||||
'<span>' + state.apps.length + ' apps</span>' +
|
||||
'<span>channel ' + esc(ch) + '</span>' +
|
||||
'<span>serial ' + esc(String(idx.index_serial != null ? idx.index_serial : '?')) + '</span>' +
|
||||
'<span>published ' + esc(idx.generated_at || '?') + '</span>';
|
||||
topchip.textContent = state.apps.length + ' apps · ' + ch;
|
||||
}
|
||||
|
||||
// ---- data ----
|
||||
function load(i) {
|
||||
i = i || 0;
|
||||
if (i >= CHANNELS.length) { grid.style.display = 'none'; empty.hidden = false; topchip.textContent = 'no catalog'; status.innerHTML = '<span class="warn">No catalog published on this host yet.</span>'; return; }
|
||||
if (i >= CHANNELS.length) { state.idx = null; topchip.textContent = 'no catalog'; render(); 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.idx = idx; state.channel = ch;
|
||||
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) {
|
||||
@ -350,32 +459,55 @@
|
||||
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
|
||||
};
|
||||
});
|
||||
renderCats(); render();
|
||||
topchip.textContent = state.apps.length + ' apps · ' + ch;
|
||||
render();
|
||||
fetch(ch + '/index.json.minisig', { method: 'HEAD', cache: 'no-store' })
|
||||
.then(function (r) { setStatus(idx, ch, r.ok); })
|
||||
.catch(function () { setStatus(idx, ch, false); });
|
||||
.then(function (r) { state.signed = r.ok; if (state.view === 'browse') render(); })
|
||||
.catch(function () {});
|
||||
})
|
||||
.catch(function () { load(i + 1); });
|
||||
}
|
||||
// Submissions are operator-published data (like the catalog): a static
|
||||
// submissions.json = { "submissions": [ {app,title,author,status,category,opened,url} ] }.
|
||||
// status: pending | review | merged. Absent file → empty state.
|
||||
function loadSubs() {
|
||||
fetch('submissions.json', { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { state.submissions = (d && Array.isArray(d.submissions)) ? d.submissions : []; renderNav(); if (state.view === 'submissions') render(); })
|
||||
.catch(function () { state.submissions = []; renderNav(); if (state.view === 'submissions') render(); });
|
||||
}
|
||||
|
||||
// ---- events (delegated on persistent elements) ----
|
||||
sidenav.addEventListener('click', function (e) {
|
||||
var p = e.target.closest('.side-pin'); if (!p) return;
|
||||
go(p.getAttribute('data-view'));
|
||||
});
|
||||
sidenav.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
var p = e.target.closest('.side-pin'); if (!p) return;
|
||||
e.preventDefault(); go(p.getAttribute('data-view'));
|
||||
});
|
||||
cats.addEventListener('click', function (e) {
|
||||
var el = e.target.closest('[data-cat]'); if (!el) return;
|
||||
clearFocus(); state.cat = el.getAttribute('data-cat'); renderCats(); render();
|
||||
clearFocus(); toBrowse(); state.cat = el.getAttribute('data-cat'); render();
|
||||
});
|
||||
document.getElementById('focusbar').addEventListener('click', function (e) {
|
||||
main.addEventListener('click', function (e) {
|
||||
var add = e.target.closest('button[data-slug]');
|
||||
if (add) {
|
||||
var txt = 'libreportal app add ' + add.getAttribute('data-slug');
|
||||
var done = function () { add.textContent = 'Copied ✓'; add.classList.add('copied'); setTimeout(function () { add.textContent = 'Add'; add.classList.remove('copied'); }, 1600); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(done, function () { add.textContent = txt; });
|
||||
else done();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#showall')) { clearFocus(); render(); }
|
||||
});
|
||||
window.addEventListener('hashchange', function () { state.focus = readFocus(); render(); });
|
||||
grid.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-slug]'); if (!b) return;
|
||||
var txt = 'libreportal app add ' + b.getAttribute('data-slug');
|
||||
var done = function () { b.textContent = 'Copied ✓'; b.classList.add('copied'); setTimeout(function () { b.textContent = 'Add'; b.classList.remove('copied'); }, 1600); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(done, function () { b.textContent = txt; });
|
||||
else done();
|
||||
});
|
||||
document.getElementById('q').addEventListener('input', function (e) { clearFocus(); state.q = e.target.value; render(); });
|
||||
document.getElementById('q').addEventListener('input', function (e) { clearFocus(); toBrowse(); state.q = e.target.value; render(); });
|
||||
window.addEventListener('hashchange', function () { state.focus = readFocus(); if (state.focus) toBrowse(); render(); });
|
||||
|
||||
render();
|
||||
load();
|
||||
loadSubs();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user