- Add a Featured sidebar row (gold star, publisher-curated meta.featured), filters to featured apps; only shown when the catalog has any. - Exact App Center font stack + 16px category text (was system-ui/14px). - 'All' (and Featured) render as inline currentColor glyphs so the All icon is white like the WebUI — all.svg uses currentColor and rendered dark as an <img>; the fixed-blue category icons stay <img>. - Apply --sidebar-bg to the sidebar itself too (double layer, matching the App Center) so the sidebar tint matches instead of showing more aurora through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
383 lines
21 KiB
HTML
383 lines
21 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>
|
||
/* 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);
|
||
--surface-hover: rgba(255,255,255,0.10);
|
||
--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 tokens — the exact nebula values (were missing/wrong, so the
|
||
category hover background + row separators resolved to nothing). */
|
||
--sidebar-bg: rgba(15,25,50,0.30);
|
||
--sidebar-border: rgba(255,255,255,0.10);
|
||
--sidebar-text: #ffffff;
|
||
}
|
||
* { 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 {
|
||
/* Exact App Center font stack (core/theme/css/base.css). */
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 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; background: var(--sidebar-bg); color: var(--sidebar-text); }
|
||
.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: 16px; 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, .category svg { 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
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\'">';
|
||
}
|
||
function renderCats() {
|
||
var list = Object.keys(counts()).sort();
|
||
var rows = [catRow('all', 'All', GRID_SVG)];
|
||
if (state.apps.some(function (a) { return a.featured; })) rows.push(catRow('__featured__', 'Featured', STAR_SVG));
|
||
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 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 === '__featured__') { if (!a.featured) return false; }
|
||
else 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 isn’t 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, featured: !!m.featured,
|
||
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>
|