/* ========================================================================= 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 = '

Could not load the catalog. Try again in a moment.

'; 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 ${DATA.total_models}`; 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} ${p.count}`; 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 ${view.length} of ${DATA.total_models} models`; if (!view.length) { GRID.innerHTML = '

No models match. Try a different search.

'; return; } const slice = view.slice(0, shown); const html = slice.map((m) => `
${escapeHtml(m.name)}
${escapeHtml(m.provider)} ${m.targets.toLocaleString()} targets ${m.stream ? 'stream' : ''}
`).join(''); const more = view.length > shown ? `` : ''; 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) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(); })();