| |
| |
| |
| |
| |
|
|
| (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; |
| let view = []; |
| let state = { q: '', provider: 'all', sort: 'targets' }; |
| const PAGE = 60; |
| let shown = PAGE; |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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(); |
| }); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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) => ( |
| { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] |
| )); |
| } |
|
|
| |
| 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(); |
| })(); |
|
|