labs / static /js /models.js
3v324v23's picture
deploy: unified router + dreamy website (2026-06-16T09:46:52Z)
c1a683f
Raw
History Blame Contribute Delete
4.75 kB
/* =========================================================================
models.js — live catalog: search + provider filter + sort + render
Loads data/models-catalog.json once, then filters entirely client-side.
Renders incrementally; caps visible cards for snappy scroll on 374 items.
========================================================================= */
(function () {
'use strict';
const GRID = document.getElementById('model-grid');
const RAIL = document.getElementById('filter-rail');
const SEARCH = document.getElementById('model-search');
const COUNT = document.getElementById('model-count');
const SORT = document.getElementById('model-sort');
if (!GRID) return;
let DATA = null; // { total_models, total_targets, providers, models }
let view = []; // current filtered+sorted slice
let state = { q: '', provider: 'all', sort: 'targets' };
const PAGE = 60; // render in pages for scroll perf
let shown = PAGE;
/* ---- load the catalog ---- */
async function load() {
try {
const res = await fetch('data/models-catalog.json', { cache: 'force-cache' });
DATA = await res.json();
} catch (e) {
GRID.innerHTML = '<div class="model-empty"><p>Could not load the catalog. Try again in a moment.</p></div>';
return;
}
buildFilters();
apply();
}
/* ---- build the provider filter rail ---- */
function buildFilters() {
const frag = document.createDocumentFragment();
const all = document.createElement('button');
all.type = 'button';
all.dataset.provider = 'all';
all.setAttribute('aria-pressed', 'true');
all.innerHTML = `All <span class="n">${DATA.total_models}</span>`;
frag.appendChild(all);
DATA.providers.forEach((p) => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.provider = p.name;
b.setAttribute('aria-pressed', 'false');
b.innerHTML = `${p.name} <span class="n">${p.count}</span>`;
frag.appendChild(b);
});
RAIL.innerHTML = '';
RAIL.appendChild(frag);
RAIL.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-provider]');
if (!btn) return;
state.provider = btn.dataset.provider;
RAIL.querySelectorAll('button').forEach((b) =>
b.setAttribute('aria-pressed', String(b === btn)));
shown = PAGE;
apply();
});
}
/* ---- filter + sort ---- */
function apply() {
const q = state.q.trim().toLowerCase();
view = DATA.models.filter((m) => {
if (state.provider !== 'all' && m.provider !== state.provider) return false;
if (!q) return true;
return (
m.name.toLowerCase().includes(q) ||
m.provider.toLowerCase().includes(q) ||
m.upstream.some((u) => u.toLowerCase().includes(q))
);
});
if (state.sort === 'targets') view.sort((a, b) => b.targets - a.targets);
else if (state.sort === 'name') view.sort((a, b) => a.name.localeCompare(b.name));
shown = PAGE;
render();
}
/* ---- render the visible page ---- */
function render() {
COUNT.innerHTML = `showing <strong>${view.length}</strong> of <strong>${DATA.total_models}</strong> models`;
if (!view.length) {
GRID.innerHTML = '<div class="model-empty"><p>No models match. Try a different search.</p></div>';
return;
}
const slice = view.slice(0, shown);
const html = slice.map((m) => `
<article class="model-card">
<div class="name">${escapeHtml(m.name)}</div>
<div class="meta">
<span class="tag">${escapeHtml(m.provider)}</span>
<span class="tag targets">${m.targets.toLocaleString()} targets</span>
${m.stream ? '<span class="tag stream">stream</span>' : ''}
</div>
</article>`).join('');
const more = view.length > shown
? `<button class="btn btn-ghost model-more" type="button" style="grid-column:1/-1;justify-self:center;margin-top:1rem;">Show more (${view.length - shown} left)</button>`
: '';
GRID.innerHTML = html + more;
const moreBtn = GRID.querySelector('.model-more');
if (moreBtn) moreBtn.addEventListener('click', () => { shown += PAGE; render(); });
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
/* ---- wire controls ---- */
let debounce;
SEARCH.addEventListener('input', (e) => {
clearTimeout(debounce);
debounce = setTimeout(() => { state.q = e.target.value; apply(); }, 120);
});
SORT.addEventListener('change', (e) => { state.sort = e.target.value; apply(); });
load();
})();