Merge claude/2

This commit is contained in:
librelad 2026-07-04 21:55:54 +01:00
commit 88bdb4d63a

View File

@ -6,110 +6,194 @@
<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. */
/* 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 {
--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);
--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;
}
* { 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));
color: var(--text-primary); min-height: 100vh; background: transparent;
}
.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;
/* 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;
}
.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);
.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. */
.apps-layout { display: flex; width: 100%; min-height: calc(100vh - 60px); }
.sidebar-container { flex-shrink: 0; width: 240px; background: var(--sidebar-bg); border-right: 1px solid var(--border-color); }
.sidebar { position: sticky; top: 60px; padding: 18px 12px; }
.apps-search { position: relative; margin-bottom: 14px; }
.apps-search input {
width: 100%; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--input-border);
background: var(--input-bg); color: var(--text-primary); font-size: 0.9rem; outline: none;
}
.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;
.apps-search input:focus { border-color: var(--accent); }
.cat-list { display: flex; flex-direction: column; gap: 2px; }
.category {
display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 10px;
cursor: pointer; color: var(--text-secondary); font-size: 0.92rem; text-transform: capitalize;
border: 1px solid transparent;
}
.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;
.category:hover { background: rgba(var(--text-rgb),0.06); color: var(--text-primary); }
.category.active { background: rgba(var(--accent-rgb),0.14); border-color: rgba(var(--accent-rgb),0.35); color: #fff; }
.category .cat-ic { width: 16px; height: 16px; opacity: 0.9; flex-shrink: 0; }
.category .cat-n { margin-left: auto; font-size: 0.75rem; opacity: 0.6; }
.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);
}
.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;
.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;
}
.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;
.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;
}
.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; }
.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; }
.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="wrap">
<header>
<div class="topbar">
<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>
<span class="brand">Libre<b>Portal</b> Marketplace</span>
<span class="spacer"></span>
<span class="chip" id="topchip">loading…</span>
</div>
<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>
<div class="apps-layout">
<div class="sidebar-container">
<div class="sidebar">
<div class="apps-search"><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="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>
<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 cats = document.getElementById('cats');
var empty = document.getElementById('empty');
var meta = document.getElementById('meta');
var status = document.getElementById('status');
var topchip = document.getElementById('topchip');
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@ -117,6 +201,57 @@
});
}
function counts() {
var by = {};
state.apps.forEach(function (a) { if (a.category) by[a.category] = (by[a.category] || 0) + 1; });
return by;
}
function renderCats() {
var by = counts();
var list = Object.keys(by).sort();
var rows = ['<div class="category' + (state.cat === 'all' ? ' active' : '') + '" data-cat="all">' +
'<svg class="cat-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>' +
'<span>All</span><span class="cat-n">' + state.apps.length + '</span></div>'];
list.forEach(function (c) {
rows.push('<div class="category' + (state.cat === c ? ' active' : '') + '" data-cat="' + esc(c) + '">' +
'<svg class="cat-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>' +
'<span>' + esc(c) + '</span><span class="cat-n">' + by[c] + '</span></div>');
});
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 shown = state.apps.filter(function (a) {
@ -124,88 +259,65 @@
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('');
grid.innerHTML = shown.map(card).join('');
grid.style.display = shown.length ? '' : 'none';
empty.hidden = shown.length > 0;
if (state.q || state.cat !== 'all') { empty.textContent = 'Nothing matches your filter.'; }
}
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 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) { empty.hidden = false; return; }
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;
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,
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
};
});
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();
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); });
}
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();
cats.addEventListener('click', function (e) {
var el = e.target.closest('[data-cat]'); if (!el) return;
state.cat = el.getAttribute('data-cat'); renderCats(); 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();
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) { state.q = e.target.value; render(); });
load();
})();