librelad 3be0e340ea fix(marketplace/site): keep the empty focus bar hidden (class display vs [hidden])
.focusbar's display:flex was overriding the hidden attribute, leaving a thin
empty bar under the status strip when no app was focused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:27:57 +01:00

369 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
/* Standalone replica of the LibrePortal App Center — same nebula theme,
aurora background and app-card design, so the marketplace looks like the
App Center it feeds. Self-contained by design: no external assets. */
:root {
--gradient-from: #1a1442; --gradient-mid: #1b2a5e; --gradient-to: #0f3b6e;
--surface-elevated: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.16); --border-color: rgba(255,255,255,0.16);
--border-subtle: rgba(255,255,255,0.08);
--text-primary: #ffffff; --text-secondary: rgba(255,255,255,0.82); --text-muted: rgba(255,255,255,0.65);
--accent: #00d4ff; --accent-hover: #0099cc; --accent-rgb: 0,212,255;
--status-success: #28a745; --status-success-hover: #218838; --status-success-rgb: 40,167,69;
--status-warning-rgb: 255,193,7;
--text-rgb: 255,255,255;
--card-bg: linear-gradient(155deg, rgba(255,255,255,0.09) 0%, rgba(0,212,255,0.05) 100%);
--card-border: rgba(255,255,255,0.16);
--card-shadow: 0 4px 18px rgba(0,0,0,0.30), inset 0 1px 0 rgba(255,255,255,0.06);
--card-shadow-hover: 0 10px 32px rgba(0,212,255,0.18), 0 4px 18px rgba(0,0,0,0.40), inset 0 1px 0 rgba(255,255,255,0.10);
--input-bg: rgba(255,255,255,0.06); --input-border: rgba(255,255,255,0.18);
--sidebar-bg: rgba(0,0,0,0.18);
}
* { box-sizing: border-box; margin: 0; }
html {
background:
radial-gradient(ellipse at 20% 30%, var(--gradient-mid) 0%, transparent 55%),
radial-gradient(ellipse at 80% 70%, var(--gradient-to) 0%, transparent 55%),
linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-from) 40%, var(--gradient-mid) 100%);
background-attachment: fixed;
}
html::before {
content: ''; position: fixed; inset: -10%; z-index: -2; pointer-events: none;
background:
radial-gradient(circle at 12% 88%, rgba(180,90,220,0.32) 0%, transparent 42%),
radial-gradient(circle at 88% 12%, rgba(255,120,180,0.22) 0%, transparent 38%),
radial-gradient(circle at 18% 22%, rgba(var(--accent-rgb),0.42) 0%, transparent 45%),
radial-gradient(circle at 78% 18%, rgba(var(--accent-rgb),0.34) 0%, transparent 42%),
radial-gradient(circle at 30% 78%, rgba(var(--accent-rgb),0.30) 0%, transparent 48%),
radial-gradient(circle at 82% 80%, rgba(var(--accent-rgb),0.40) 0%, transparent 46%);
}
html::after {
content: ''; position: fixed; inset: 0; z-index: -1; pointer-events: none;
background-image:
radial-gradient(1.5px 1.5px at 12px 18px, rgba(var(--text-rgb),0.9), transparent 60%),
radial-gradient(1px 1px at 47px 92px, rgba(var(--accent-rgb),0.85), transparent 60%),
radial-gradient(1.2px 1.2px at 110px 40px, rgba(var(--text-rgb),0.75), transparent 60%),
radial-gradient(1px 1px at 165px 130px, rgba(var(--accent-rgb),0.70), transparent 60%);
background-size: 200px 200px;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text-primary); min-height: 100vh; background: transparent;
}
/* Top bar — the App Center chrome. */
.topbar {
height: 60px; display: flex; align-items: center; gap: 14px; padding: 0 22px;
background: rgba(0,0,0,0.22); border-bottom: 1px solid var(--border-color); backdrop-filter: blur(6px);
position: sticky; top: 0; z-index: 5;
}
.topbar img { width: 34px; height: 34px; }
.topbar .brand { font-weight: 700; font-size: 1.15rem; letter-spacing: 0.2px; }
.topbar .brand b { color: var(--accent); }
.topbar .spacer { flex: 1; }
.topbar .chip { font-size: 0.8rem; color: var(--text-muted); border: 1px solid var(--border-color); padding: 5px 11px; border-radius: 999px; }
/* Layout: sidebar + main — matches the App Center (apps-layout.css /
sidebar.css): a 220px full-height column, full-width category rows with
bottom-border separators, and the same search box. */
.apps-layout { display: flex; width: 100%; min-height: calc(100vh - 60px); }
.sidebar-container {
flex-shrink: 0; width: 220px; background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
backdrop-filter: blur(12px) saturate(140%); -webkit-backdrop-filter: blur(12px) saturate(140%);
}
.sidebar { position: sticky; top: 60px; display: flex; flex-direction: column; }
.apps-search { position: relative; padding: 14px 20px; border-bottom: 1px solid var(--sidebar-border); }
.apps-search-icon { position: absolute; left: 32px; top: 50%; transform: translateY(-50%); color: rgba(var(--text-rgb),0.55); pointer-events: none; }
.apps-search input {
width: 100%; padding: 9px 12px 9px 36px; border-radius: 8px; border: 1px solid rgba(var(--text-rgb),0.12);
background: rgba(var(--text-rgb),0.06); color: var(--text-primary); font-size: 13px; outline: none;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.apps-search input::placeholder { color: rgba(var(--text-rgb),0.5); }
.apps-search input:focus { background: rgba(var(--text-rgb),0.10); border-color: rgba(var(--accent-rgb),0.55); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),0.16); }
.cat-list { display: flex; flex-direction: column; }
.category {
display: flex; align-items: center; gap: 10px; padding: 15px 20px;
cursor: pointer; color: var(--text-secondary); font-size: 14px; text-transform: capitalize;
border-bottom: 1px solid var(--sidebar-border); transition: background 0.2s, color 0.2s;
}
.category:hover, .category.active { background: var(--surface-hover); color: var(--text-primary); }
.category img { width: 20px; height: 20px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; }
.status-strip {
display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin: 22px 22px 0; padding: 10px 16px;
background: rgba(var(--text-rgb),0.025); border: 1px solid var(--border-subtle); border-radius: 12px; font-size: 0.85rem; color: var(--text-muted);
}
.status-strip .ok { color: #67e8f9; font-weight: 600; }
.status-strip .warn { color: #fcd34d; font-weight: 600; }
/* The grid + cards — replicated from the App Center (apps.css). */
.apps-section {
display: grid; grid-template-columns: repeat(auto-fit, minmax(328px, 1fr)); gap: 20px;
margin: 22px; padding: 22px; background: rgba(var(--text-rgb),0.025);
border: 1px solid var(--border-subtle); border-radius: 16px;
}
.app-card {
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px;
display: flex; flex-direction: column; gap: 16px; box-shadow: var(--card-shadow);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; min-height: 120px;
}
.app-card:hover { transform: translateY(-2px); box-shadow: var(--card-shadow-hover); border-color: rgba(var(--accent-rgb),0.4); }
.app-card-top { display: flex; align-items: flex-start; gap: 16px; }
.app-card-icon {
width: 70px; height: 70px; background: rgba(var(--text-rgb),0.1); border-radius: 12px;
display: flex; align-items: center; justify-content: center; padding: 12px;
border: 1px solid rgba(var(--text-rgb),0.2); flex-shrink: 0;
}
.app-card-icon img { width: 100%; height: 100%; object-fit: contain; }
.app-card-content { flex: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.app-card-title { font-size: 16px; font-weight: 600; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-card-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.app-tag {
display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 12px;
font-size: 12px; font-weight: 500; border: 1px solid;
}
.app-tag.description-tag { background: rgba(var(--text-rgb),0.1); color: var(--text-primary); border-color: rgba(var(--text-rgb),0.2); font-style: italic; }
.app-tag.category-tag { background: rgba(var(--accent-rgb),0.1); color: var(--accent); border-color: rgba(var(--accent-rgb),0.2); text-transform: capitalize; }
.app-tag.available-tag { background: rgba(99,102,241,0.30); color: #a5b4fc; border-color: rgba(99,102,241,0.65); }
.app-tag.available-tag::before { content: '↓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.installed-tag { background: rgba(var(--status-success-rgb),0.35); color: #86efac; border-color: rgba(var(--status-success-rgb),0.70); }
.app-tag.installed-tag::before { content: '✓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.trust-badge { background: rgba(34,211,238,0.22); color: #67e8f9; border-color: rgba(34,211,238,0.55); }
.app-tag.trust-badge::before { content: '✓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.community-badge { background: rgba(var(--status-warning-rgb),0.2); color: #fcd34d; border-color: rgba(var(--status-warning-rgb),0.5); }
.app-card-long-description {
font-size: 11px; color: var(--text-secondary); line-height: 1.3; font-style: italic;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.app-card-actions { display: flex; gap: 8px; margin-top: auto; }
.app-card-actions button {
flex: 1; padding: 10px 14px; border-radius: 8px; font-weight: 600; font-size: 0.9rem; cursor: pointer;
background: #6366f1; color: #fff; border: 1px solid #6366f1; transition: background 0.15s ease;
}
.app-card-actions button:hover { background: #4f46e5; border-color: #4f46e5; }
.app-card-actions button.copied { background: var(--status-success); border-color: var(--status-success); }
.app-card-actions button.installed-btn { background: transparent; border-color: var(--border-color); color: var(--text-secondary); cursor: default; }
.focusbar {
display: flex; align-items: center; gap: 12px; margin: 16px 22px 0; padding: 10px 16px;
background: rgba(99,102,241,0.14); border: 1px solid rgba(99,102,241,0.4); border-radius: 12px; font-size: 0.9rem;
}
.focusbar[hidden] { display: none; } /* class display: mustn't defeat the hidden attr */
.focusbar button { background: transparent; border: 1px solid var(--border-color); color: var(--text-primary); padding: 5px 12px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.focusbar button:hover { background: rgba(var(--text-rgb),0.08); }
.empty, .footnote { color: var(--text-muted); }
.empty { text-align: center; padding: 48px 20px; margin: 22px; border: 1px dashed var(--border-color); border-radius: 12px; }
.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; }
</style>
</head>
<body>
<div class="topbar">
<img src="marketplace.svg" alt="">
<span class="brand">Libre<b>Portal</b> Marketplace</span>
<span class="spacer"></span>
<span class="chip" id="topchip">loading…</span>
</div>
<div class="apps-layout">
<div class="sidebar-container">
<div class="sidebar">
<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">
</div>
<div class="cat-list" id="cats"></div>
</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>
<script>
(function () {
'use strict';
var CHANNELS = ['stable', 'edge'];
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 cats = document.getElementById('cats');
var empty = document.getElementById('empty');
var status = document.getElementById('status');
var topchip = document.getElementById('topchip');
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function counts() {
var by = {};
state.apps.forEach(function (a) { if (a.category) by[a.category] = (by[a.category] || 0) + 1; });
return by;
}
function catRow(id, label) {
// Real App Center category glyphs (bundled into the site by the install
// hook); misc.svg is the fallback for an unknown category.
return '<div class="category' + (state.cat === id ? ' active' : '') + '" data-cat="' + esc(id) + '">' +
'<img src="categories/' + esc(id) + '.svg" alt="" onerror="this.src=\'categories/misc.svg\'">' +
'<span>' + esc(label) + '</span></div>';
}
function renderCats() {
var list = Object.keys(counts()).sort();
var rows = [catRow('all', 'All')];
list.forEach(function (c) { rows.push(catRow(c, 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 render() {
var q = state.q.toLowerCase();
var focus = state.focus; // #<slug> deep-link → show just that app
var shown = state.apps.filter(function (a) {
if (focus) return a.slug === focus;
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;
});
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.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 isnt on this channel.';
else if (state.q || state.cat !== 'all') empty.textContent = 'Nothing matches your filter.';
}
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;
}
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; }
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, installed: false,
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
};
});
renderCats(); 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); });
})
.catch(function () { load(i + 1); });
}
cats.addEventListener('click', function (e) {
var el = e.target.closest('[data-cat]'); if (!el) return;
clearFocus(); state.cat = el.getAttribute('data-cat'); renderCats(); render();
});
document.getElementById('focusbar').addEventListener('click', function (e) {
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(); });
load();
})();
</script>
</body>
</html>