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>
515 lines
33 KiB
HTML
515 lines
33 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; }
|
||
|
||
/* 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>
|
||
<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">
|
||
<!-- 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">
|
||
</div>
|
||
<div class="cat-list" id="cats"></div>
|
||
</div>
|
||
</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 = { 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 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) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
// ---- sidebar ----
|
||
function counts() {
|
||
var by = {};
|
||
state.apps.forEach(function (a) { if (a.category) by[a.category] = (by[a.category] || 0) + 1; });
|
||
return by;
|
||
}
|
||
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) {
|
||
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)];
|
||
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 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; }
|
||
}
|
||
|
||
// ---- 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; }
|
||
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;
|
||
});
|
||
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;
|
||
}
|
||
grid.innerHTML = shown.map(card).join('');
|
||
grid.style.display = shown.length ? '' : 'none';
|
||
empty.hidden = shown.length > 0;
|
||
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 = ''; } }
|
||
}
|
||
|
||
// ---- data ----
|
||
function load(i) {
|
||
i = i || 0;
|
||
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) {
|
||
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
|
||
};
|
||
});
|
||
topchip.textContent = state.apps.length + ' apps · ' + ch;
|
||
render();
|
||
fetch(ch + '/index.json.minisig', { method: 'HEAD', cache: 'no-store' })
|
||
.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(); toBrowse(); state.cat = el.getAttribute('data-cat'); render();
|
||
});
|
||
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(); }
|
||
});
|
||
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>
|
||
</html>
|