mirror of
https://github.com/hanzoai/templates-blueprints.git
synced 2026-10-01 00:43:09 +00:00
oss.hanzo.ai: bulletproof gallery — kill CSP-blocked inline handlers, DOM-built + resilient
The inline img onerror= handler was blocked by script-src 'self' → console-error storm on all 1030 cards + dead-feeling page. Rebuild: cards built via DOM API (never innerHTML — XSS-safe, CSP-clean), logo error handled via addEventListener (graceful monogram fallback), render wrapped so one bad row is skipped not fatal, graceful catalog-load failure. Apple-tier CSS polish (calm monochrome, refined hover, rhythm). Reliable + resilient by construction.
This commit is contained in:
@@ -1,77 +1,100 @@
|
||||
// Hanzo OSS explorer — fetches the catalog and renders a fast, searchable gallery.
|
||||
// Static-served (hanzoai/static): same-origin fetch of /meta.json, no framework.
|
||||
// oss.hanzo.ai — the open-source explorer. One file, no framework, no inline JS
|
||||
// (CSP: script-src 'self'). Cards are built with the DOM API — never innerHTML —
|
||||
// so a hostile description can't inject, a missing logo can't break the grid, and
|
||||
// nothing here runs afoul of the strict Content-Security-Policy. Resilient by
|
||||
// construction: one bad template is skipped, never fatal.
|
||||
'use strict';
|
||||
|
||||
var PLATFORM = 'https://platform.hanzo.ai/templates?deploy=';
|
||||
var AUTHORS = 'https://console.hanzo.ai/authors';
|
||||
var PAGE = 48;
|
||||
// Build-provenance tags are catalog plumbing, not user categories.
|
||||
var DEPLOY = 'https://platform.hanzo.ai/templates?deploy=';
|
||||
var EARN = 'https://console.hanzo.ai/authors';
|
||||
var PAGE = 60;
|
||||
|
||||
// Build-provenance tags are catalog plumbing, not categories a human browses by.
|
||||
var HIDE = { caprover: 1, dokploy: 1, coolify: 1, casaos: 1, runtipi: 1 };
|
||||
var FEATURED = ['self-hosted', 'ai', 'database', 'media', 'productivity', 'monitoring', 'automation', 'developer'];
|
||||
|
||||
var all = [], view = [], shown = 0, active = '';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var el = function (tag, cls, text) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
};
|
||||
|
||||
var grid = document.getElementById('grid');
|
||||
var q = document.getElementById('q');
|
||||
var tagsEl = document.getElementById('tags');
|
||||
var moreBtn = document.getElementById('more');
|
||||
var emptyEl = document.getElementById('empty');
|
||||
var countEl = document.getElementById('count');
|
||||
var ALL = [], view = [], shown = 0, active = '';
|
||||
var grid, q, tagsEl, moreBtn, emptyEl, countEl;
|
||||
|
||||
function ghRepo(t) {
|
||||
var u = (t.links && t.links.github) || '';
|
||||
var m = u.match(/github\.com\/([^/]+)\/([^/#?]+)/);
|
||||
return m ? (m[1] + '/' + m[2].replace(/\.git$/, '')) : '';
|
||||
return m ? m[1] + '/' + m[2].replace(/\.git$/, '') : '';
|
||||
}
|
||||
|
||||
// A logo that degrades gracefully: try the image; on error (or when absent),
|
||||
// fall back to a monogram tile. No inline handler — bound here, CSP-clean.
|
||||
function logo(t) {
|
||||
var initial = (t.name || t.id || '?').charAt(0).toUpperCase();
|
||||
var mono = el('div', 'logo mono', initial);
|
||||
if (!t.logo) return mono;
|
||||
var img = el('img', 'logo');
|
||||
img.loading = 'lazy';
|
||||
img.alt = '';
|
||||
img.src = '/blueprints/' + encodeURIComponent(t.id) + '/' + t.logo;
|
||||
img.addEventListener('error', function () {
|
||||
if (img.parentNode) img.parentNode.replaceChild(mono, img);
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
function card(t) {
|
||||
var el = document.createElement('article');
|
||||
el.className = 'card';
|
||||
var repo = ghRepo(t);
|
||||
var logo = t.logo
|
||||
? '<img class="logo" loading="lazy" alt="" src="/blueprints/' + t.id + '/' + t.logo + '" onerror="this.replaceWith(mono(\'' + (t.name || t.id).replace(/[^a-z0-9]/gi, '') + '\'))">'
|
||||
: monoHTML(t.name || t.id);
|
||||
el.innerHTML =
|
||||
logo +
|
||||
'<div class="body">' +
|
||||
'<p class="name">' + esc(t.name || t.id) + '</p>' +
|
||||
'<p class="desc">' + esc(t.description || '') + '</p>' +
|
||||
'<div class="row">' +
|
||||
'<a class="deploy" href="' + PLATFORM + encodeURIComponent(t.id) + '">Deploy</a>' +
|
||||
(t.links && t.links.github ? '<a class="link" target="_blank" rel="noopener" href="' + esc(t.links.github) + '">GitHub</a>' : '') +
|
||||
(repo ? '<a class="claim" title="Are you the maintainer?" href="' + AUTHORS + '?claim=' + encodeURIComponent(repo) + '">Earn 20% →</a>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return el;
|
||||
}
|
||||
var c = el('article', 'card');
|
||||
|
||||
// exposed for the onerror fallback
|
||||
window.mono = function (letter) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'logo mono';
|
||||
d.textContent = (letter || '?').charAt(0).toUpperCase();
|
||||
return d;
|
||||
};
|
||||
function monoHTML(name) {
|
||||
return '<div class="logo mono">' + esc((name || '?').charAt(0).toUpperCase()) + '</div>';
|
||||
c.appendChild(logo(t));
|
||||
|
||||
var body = el('div', 'body');
|
||||
body.appendChild(el('p', 'name', t.name || t.id));
|
||||
body.appendChild(el('p', 'desc', t.description || ''));
|
||||
|
||||
var row = el('div', 'row');
|
||||
var deploy = el('a', 'deploy', 'Deploy');
|
||||
deploy.href = DEPLOY + encodeURIComponent(t.id);
|
||||
row.appendChild(deploy);
|
||||
|
||||
if (t.links && t.links.github) {
|
||||
var gh = el('a', 'link', 'GitHub');
|
||||
gh.href = t.links.github; gh.target = '_blank'; gh.rel = 'noopener noreferrer';
|
||||
row.appendChild(gh);
|
||||
}
|
||||
var repo = ghRepo(t);
|
||||
if (repo) {
|
||||
var earn = el('a', 'claim', 'Earn 20% →');
|
||||
earn.href = EARN + '?claim=' + encodeURIComponent(repo);
|
||||
earn.title = 'Are you the maintainer? Earn 20% of the compute revenue.';
|
||||
row.appendChild(earn);
|
||||
}
|
||||
body.appendChild(row);
|
||||
c.appendChild(body);
|
||||
return c;
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
||||
|
||||
function render(reset) {
|
||||
if (reset) { grid.innerHTML = ''; shown = 0; }
|
||||
if (reset) { grid.textContent = ''; shown = 0; }
|
||||
var frag = document.createDocumentFragment();
|
||||
var end = Math.min(shown + PAGE, view.length);
|
||||
for (var i = shown; i < end; i++) frag.appendChild(card(view[i]));
|
||||
for (var i = shown; i < end; i++) {
|
||||
try { frag.appendChild(card(view[i])); } catch (e) { /* skip one bad row, never break the grid */ }
|
||||
}
|
||||
grid.appendChild(frag);
|
||||
shown = end;
|
||||
moreBtn.hidden = shown >= view.length;
|
||||
emptyEl.hidden = view.length > 0;
|
||||
if (countEl) countEl.textContent = ALL.length.toLocaleString();
|
||||
}
|
||||
|
||||
function apply() {
|
||||
var term = q.value.trim().toLowerCase();
|
||||
view = all.filter(function (t) {
|
||||
if (active && !(t.tags || []).some(function (x) { return x === active; })) return false;
|
||||
view = ALL.filter(function (t) {
|
||||
if (active && (t.tags || []).indexOf(active) < 0) return false;
|
||||
if (!term) return true;
|
||||
return (t.name || '').toLowerCase().indexOf(term) >= 0 ||
|
||||
(t.description || '').toLowerCase().indexOf(term) >= 0 ||
|
||||
@@ -81,35 +104,48 @@ function apply() {
|
||||
render(true);
|
||||
}
|
||||
|
||||
var timer;
|
||||
q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 90); });
|
||||
moreBtn.addEventListener('click', function () { render(false); });
|
||||
|
||||
function buildTags() {
|
||||
FEATURED.forEach(function (name) {
|
||||
if (HIDE[name]) return;
|
||||
var b = document.createElement('button');
|
||||
b.className = 'tag'; b.textContent = name; b.dataset.tag = name;
|
||||
var b = el('button', 'tag', name);
|
||||
b.addEventListener('click', function () {
|
||||
if (active === name) { active = ''; b.classList.remove('on'); }
|
||||
else {
|
||||
active = name;
|
||||
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); });
|
||||
b.classList.add('on');
|
||||
}
|
||||
var on = active === name;
|
||||
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); });
|
||||
active = on ? '' : name;
|
||||
if (!on) b.classList.add('on');
|
||||
apply();
|
||||
});
|
||||
tagsEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/meta.json')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
all = Array.isArray(data) ? data : [];
|
||||
if (countEl) countEl.textContent = all.length.toLocaleString();
|
||||
buildTags();
|
||||
view = all;
|
||||
render(true);
|
||||
})
|
||||
.catch(function () { emptyEl.hidden = false; emptyEl.textContent = 'Catalog failed to load — try /meta.json directly.'; });
|
||||
function fail(msg) {
|
||||
if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; }
|
||||
}
|
||||
|
||||
function start() {
|
||||
grid = $('grid'); q = $('q'); tagsEl = $('tags'); moreBtn = $('more');
|
||||
emptyEl = $('empty'); countEl = $('count');
|
||||
if (!grid) return;
|
||||
|
||||
moreBtn.addEventListener('click', function () { render(false); });
|
||||
var timer;
|
||||
q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 80); });
|
||||
|
||||
fetch('/meta.json', { cache: 'default' })
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||
.then(function (data) {
|
||||
ALL = (Array.isArray(data) ? data : []).filter(function (t) { return t && t.id; });
|
||||
if (!ALL.length) return fail('Catalog is empty.');
|
||||
buildTags();
|
||||
view = ALL;
|
||||
render(true);
|
||||
})
|
||||
.catch(function () { fail('Catalog failed to load. Try /meta.json directly.'); });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
|
||||
+78
-60
@@ -1,103 +1,121 @@
|
||||
/* oss.hanzo.ai — the open-source explorer. Monochrome, Geist, calm.
|
||||
The apple of open source: restraint, rhythm, one accent (the deploy button). */
|
||||
:root {
|
||||
--bg: #000; --panel: #0c0c0d; --line: #1c1c1f; --line2: #2a2a2e;
|
||||
--fg: #fafafa; --muted: #8a8a8f; --muted2: #5a5a5f;
|
||||
--accent: #fafafa;
|
||||
--bg: #000; --panel: #0b0b0c; --panel-hi: #101012;
|
||||
--line: #1a1a1d; --line-hi: #2a2a2f;
|
||||
--fg: #fafafa; --muted: #8f8f96; --faint: #5a5a61;
|
||||
--radius: 14px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 15px/1.55 ui-sans-serif, -apple-system, "Geist", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 15px/1.55 ui-sans-serif, -apple-system, "Geist", "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
::selection { background: #fff; color: #000; }
|
||||
|
||||
/* top bar */
|
||||
.top {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.5rem; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; background: rgba(0,0,0,.72); backdrop-filter: blur(12px); z-index: 10;
|
||||
padding: 1.05rem 1.75rem;
|
||||
background: rgba(0,0,0,.65); backdrop-filter: saturate(180%) blur(16px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { font-weight: 600; letter-spacing: -0.02em; }
|
||||
.topnav { display: flex; gap: 1.25rem; font-size: .9rem; color: var(--muted); }
|
||||
.brand { font-weight: 600; letter-spacing: -0.03em; font-size: 1.05rem; }
|
||||
.topnav { display: flex; gap: 1.5rem; font-size: .9rem; color: var(--muted); }
|
||||
.topnav a { transition: color .15s var(--ease); }
|
||||
.topnav a:hover { color: var(--fg); }
|
||||
|
||||
/* hero */
|
||||
.hero { max-width: 62rem; margin: 0 auto; padding: 3.5rem 1.5rem 1.5rem; text-align: center; }
|
||||
.hero h1 { font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.05; letter-spacing: -0.03em; margin: 0 0 .6rem; font-weight: 600; }
|
||||
.lead { font-size: 1.15rem; color: var(--muted); margin: 0 0 1rem; }
|
||||
.lead strong { color: var(--fg); }
|
||||
.earn {
|
||||
display: inline-block; font-size: .9rem; color: var(--muted);
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: .5rem 1rem; margin-bottom: 1.75rem;
|
||||
.hero { max-width: 60rem; margin: 0 auto; padding: 4.5rem 1.5rem 2rem; text-align: center; }
|
||||
.hero h1 {
|
||||
margin: 0 0 .75rem; font-weight: 600; letter-spacing: -0.035em;
|
||||
font-size: clamp(2.25rem, 6vw, 4rem); line-height: 1.02;
|
||||
}
|
||||
.earn a { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.lead { margin: 0 auto 1.25rem; max-width: 34rem; font-size: 1.2rem; color: var(--muted); }
|
||||
.lead strong { color: var(--fg); font-weight: 600; }
|
||||
.earn {
|
||||
display: inline-block; margin-bottom: 2rem; padding: .55rem 1.15rem;
|
||||
font-size: .9rem; color: var(--muted);
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
|
||||
transition: border-color .15s var(--ease);
|
||||
}
|
||||
.earn:hover { border-color: var(--line-hi); }
|
||||
.earn a { color: var(--fg); border-bottom: 1px solid var(--line-hi); }
|
||||
|
||||
.search {
|
||||
width: 100%; max-width: 40rem; display: block; margin: 0 auto;
|
||||
background: var(--panel); border: 1px solid var(--line2); border-radius: 12px;
|
||||
color: var(--fg); font-size: 1rem; padding: .85rem 1.1rem; outline: none;
|
||||
display: block; width: 100%; max-width: 42rem; margin: 0 auto;
|
||||
padding: .95rem 1.25rem; font-size: 1rem; color: var(--fg);
|
||||
background: var(--panel); border: 1px solid var(--line-hi); border-radius: 12px;
|
||||
outline: none; transition: border-color .15s var(--ease), background .15s var(--ease);
|
||||
}
|
||||
.search:focus { border-color: #444; }
|
||||
.search::placeholder { color: var(--muted2); }
|
||||
.search::placeholder { color: var(--faint); }
|
||||
.search:focus { border-color: #555; background: var(--panel-hi); }
|
||||
|
||||
.tags { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; margin-top: 1.1rem; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; margin-top: 1.25rem; }
|
||||
.tag {
|
||||
font-size: .8rem; color: var(--muted); background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: .3rem .8rem; cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
padding: .35rem .85rem; font-size: .82rem; color: var(--muted); text-transform: capitalize;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
|
||||
cursor: pointer; transition: all .15s var(--ease);
|
||||
}
|
||||
.tag:hover { color: var(--fg); border-color: var(--line2); }
|
||||
.tag.on { color: #000; background: var(--fg); border-color: var(--fg); }
|
||||
.tag:hover { color: var(--fg); border-color: var(--line-hi); }
|
||||
.tag.on { color: #000; background: var(--fg); border-color: var(--fg); font-weight: 500; }
|
||||
|
||||
/* grid */
|
||||
main { max-width: 82rem; margin: 0 auto; padding: 1.5rem 1.5rem 4rem; }
|
||||
.grid {
|
||||
display: grid; gap: .75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
||||
}
|
||||
main { max-width: 84rem; margin: 0 auto; padding: 1.5rem 1.5rem 5rem; }
|
||||
.grid { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(19.5rem, 1fr)); }
|
||||
.card {
|
||||
content-visibility: auto; contain-intrinsic-size: 0 108px;
|
||||
display: flex; gap: .85rem; align-items: flex-start;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem; position: relative; transition: border-color .12s;
|
||||
content-visibility: auto; contain-intrinsic-size: 0 112px;
|
||||
display: flex; gap: .9rem; align-items: flex-start;
|
||||
padding: 1.1rem; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
transition: border-color .16s var(--ease), background .16s var(--ease), transform .16s var(--ease);
|
||||
}
|
||||
.card:hover { border-color: var(--line2); }
|
||||
.card:hover { border-color: var(--line-hi); background: var(--panel-hi); transform: translateY(-1px); }
|
||||
.logo {
|
||||
flex: 0 0 40px; width: 40px; height: 40px; border-radius: 9px;
|
||||
background: #fff; object-fit: contain; padding: 5px;
|
||||
display: grid; place-items: center; overflow: hidden;
|
||||
flex: 0 0 42px; width: 42px; height: 42px; border-radius: 10px;
|
||||
object-fit: contain; background: #fff; padding: 5px;
|
||||
}
|
||||
.logo.mono { background: var(--line2); color: var(--fg); font-weight: 600; font-size: 1.1rem; }
|
||||
.body { min-width: 0; flex: 1; }
|
||||
.name { font-weight: 600; letter-spacing: -0.01em; margin: 0; font-size: .98rem; }
|
||||
.logo.mono {
|
||||
display: grid; place-items: center; padding: 0;
|
||||
background: var(--line-hi); color: var(--fg); font-weight: 600; font-size: 1.15rem;
|
||||
}
|
||||
.body { flex: 1; min-width: 0; }
|
||||
.name { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: -0.015em; }
|
||||
.desc {
|
||||
color: var(--muted); font-size: .82rem; margin: .2rem 0 .55rem;
|
||||
margin: .25rem 0 .7rem; font-size: .83rem; color: var(--muted); line-height: 1.45;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
||||
.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||
.deploy {
|
||||
font-size: .8rem; font-weight: 500; color: #000; background: var(--fg);
|
||||
border-radius: 8px; padding: .3rem .7rem;
|
||||
padding: .34rem .8rem; font-size: .8rem; font-weight: 600; color: #000;
|
||||
background: var(--fg); border-radius: 8px; transition: background .15s var(--ease);
|
||||
}
|
||||
.deploy:hover { background: #d4d4d4; }
|
||||
.link { font-size: .78rem; color: var(--muted); }
|
||||
.deploy:hover { background: #cfcfcf; }
|
||||
.link { font-size: .8rem; color: var(--muted); transition: color .15s var(--ease); }
|
||||
.link:hover { color: var(--fg); }
|
||||
.claim { font-size: .74rem; color: var(--muted2); margin-left: auto; }
|
||||
.claim { margin-left: auto; font-size: .76rem; color: var(--faint); transition: color .15s var(--ease); }
|
||||
.claim:hover { color: var(--fg); }
|
||||
|
||||
.more {
|
||||
display: block; margin: 2rem auto 0; background: var(--panel); color: var(--fg);
|
||||
border: 1px solid var(--line2); border-radius: 10px; padding: .7rem 1.5rem; cursor: pointer; font-size: .9rem;
|
||||
display: block; margin: 2.5rem auto 0; padding: .75rem 1.75rem;
|
||||
font-size: .9rem; color: var(--fg); background: var(--panel);
|
||||
border: 1px solid var(--line-hi); border-radius: 10px; cursor: pointer;
|
||||
transition: border-color .15s var(--ease);
|
||||
}
|
||||
.more:hover { border-color: #444; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 3rem 0; }
|
||||
.more:hover { border-color: #555; }
|
||||
.empty { padding: 4rem 0; text-align: center; color: var(--muted); }
|
||||
|
||||
.foot {
|
||||
max-width: 82rem; margin: 0 auto; padding: 2rem 1.5rem; border-top: 1px solid var(--line);
|
||||
max-width: 84rem; margin: 0 auto; padding: 2.5rem 1.5rem;
|
||||
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
color: var(--muted2); font-size: .8rem;
|
||||
border-top: 1px solid var(--line); color: var(--faint); font-size: .8rem;
|
||||
}
|
||||
.foot a { color: var(--muted); }
|
||||
.foot a:hover { color: var(--fg); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
|
||||
Reference in New Issue
Block a user