Chunte HF Staff commited on
Commit
d094e2b
Β·
verified Β·
1 Parent(s): a4ea100

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +302 -22
index.html CHANGED
@@ -42,10 +42,10 @@ canvas { display: block; }
42
  text-decoration: none;
43
  }
44
 
45
- /* ── Add Model Pill Button ── */
46
  #addModelBtn {
47
  position: fixed;
48
- bottom: 24px;
49
  right: 24px;
50
  z-index: 10;
51
  }
@@ -195,6 +195,116 @@ canvas { display: block; }
195
  @keyframes pillSpin {
196
  to { transform: rotate(360deg); }
197
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  </style>
199
  </head>
200
  <body>
@@ -213,8 +323,15 @@ canvas { display: block; }
213
  <div class="active-wrap">
214
  <span class="orange-dot"></span>
215
  <span class="model-name"></span>
216
- <button class="remove-btn">Γ—</button>
 
 
 
 
 
 
217
  </div>
 
218
  </div>
219
  </div>
220
  <script>
@@ -312,6 +429,7 @@ const FALLBACK = [
312
  ];
313
 
314
  let models;
 
315
  try {
316
  // Fetch trending models, then pick the top 10 by downloads
317
  const res = await fetch('https://huggingface.co/api/models?sort=trendingScore&limit=50');
@@ -330,16 +448,18 @@ try {
330
  });
331
  // Sort by downloads descending and take top 10
332
  all.sort((a, b) => b.downloads - a.downloads);
 
333
  models = all.slice(0, 10);
334
  } catch (e) {
335
  models = FALLBACK.map(m => {
336
  const author = m.id.includes('/') ? m.id.split('/')[0] : '';
337
  return { ...m, author: author, avatarUrl: '' };
338
  });
 
339
  }
340
 
341
- // Fetch author avatars in parallel (non-blocking)
342
- const uniqueAuthors = [...new Set(models.map(m => m.author).filter(Boolean))];
343
  const avatarMap = {};
344
  await Promise.all(uniqueAuthors.map(async (author) => {
345
  try {
@@ -353,6 +473,9 @@ await Promise.all(uniqueAuthors.map(async (author) => {
353
  for (const m of models) {
354
  if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author];
355
  }
 
 
 
356
 
357
  // ── Mass Mapping ───────────────────────────────────────────
358
  const D = models.map(m => m.downloads);
@@ -1341,15 +1464,25 @@ function buildColoredSVGImage(svgText, hexColor, renderSize) {
1341
  return img;
1342
  }
1343
 
 
 
 
 
 
 
 
 
 
 
 
1344
  // Preload all icon SVGs in both COLOR and ORANGE variants
1345
  async function preloadTaskIcons() {
1346
  const colors = [COLOR, ORANGE];
1347
  const entries = Object.entries(TASK_ICON_FILES);
1348
  await Promise.all(entries.map(async ([task, file]) => {
1349
  try {
1350
- const res = await fetch(ICON_ASSET_DIR + file);
1351
- if (!res.ok) return;
1352
- const svgText = await res.text();
1353
  taskIconImgs[task] = {};
1354
  for (const c of colors) {
1355
  const img = buildColoredSVGImage(svgText, c, ICON_RENDER_SIZE);
@@ -1736,22 +1869,27 @@ canvas.addEventListener('mouseleave', () => {
1736
  cursorOnCanvas = false; // triggers spring snapback
1737
  });
1738
 
1739
- // ── Custom Model: Pill Button Logic ─────────────────────────
1740
  const addPill = document.getElementById('addPill');
1741
  const pillInput = addPill.querySelector('input[type="text"]');
1742
  const pillAddBtn = addPill.querySelector('.add-btn');
1743
  const pillError = addPill.querySelector('.error-msg');
1744
  const pillModelName = addPill.querySelector('.model-name');
1745
  const pillRemoveBtn = addPill.querySelector('.remove-btn');
 
 
 
1746
 
1747
  // Pill states: 'idle' | 'expanded' | 'loading' | 'active'
1748
  let pillState = 'idle';
1749
  let pillAnim = null;
 
 
 
 
1750
 
1751
  function animatePill(toState) {
1752
- // Capture current width
1753
  const fromW = addPill.offsetWidth;
1754
- // Apply new state classes
1755
  addPill.classList.remove('expanded', 'active', 'loading');
1756
  pillError.textContent = '';
1757
  pillError.style.display = 'none';
@@ -1765,9 +1903,7 @@ function animatePill(toState) {
1765
  const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id;
1766
  pillModelName.textContent = shortName;
1767
  }
1768
- // Measure target width
1769
  const toW = addPill.offsetWidth;
1770
- // Animate width with spring-like easing
1771
  if (pillAnim) pillAnim.cancel();
1772
  if (fromW !== toW) {
1773
  pillAnim = addPill.animate(
@@ -1779,21 +1915,136 @@ function animatePill(toState) {
1779
  }
1780
 
1781
  function setPillState(state) {
1782
- const prev = pillState;
1783
  pillState = state;
1784
  animatePill(state);
1785
  if (state === 'expanded') {
1786
  setTimeout(() => pillInput.focus(), 80);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1787
  }
1788
  }
1789
 
 
 
1790
  // Idle β†’ click β†’ expand
1791
  addPill.addEventListener('click', (e) => {
1792
  if (pillState === 'idle') {
1793
  setPillState('expanded');
1794
  e.stopPropagation();
1795
  } else if (pillState === 'active') {
1796
- // Click on pill body (not remove btn) β†’ replace: remove current, expand
1797
  if (!e.target.classList.contains('remove-btn')) {
1798
  removeCustomModel();
1799
  setPillState('expanded');
@@ -1817,7 +2068,7 @@ pillAddBtn.addEventListener('click', (e) => {
1817
  if (val) submitCustomModel(val);
1818
  });
1819
 
1820
- // Enter key in input
1821
  pillInput.addEventListener('keydown', (e) => {
1822
  if (e.key === 'Enter') {
1823
  e.preventDefault();
@@ -1828,19 +2079,50 @@ pillInput.addEventListener('keydown', (e) => {
1828
  }
1829
  });
1830
 
1831
- // Click input to prevent propagation
1832
  pillInput.addEventListener('click', (e) => e.stopPropagation());
1833
 
1834
- // Click outside β†’ collapse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1835
  document.addEventListener('click', (e) => {
1836
- if (pillState === 'expanded' && !addPill.contains(e.target)) {
1837
  setPillState(customModelActive ? 'active' : 'idle');
1838
  }
1839
  });
1840
 
1841
  // ── submitCustomModel: fetch model data from HF API ─────────
1842
  async function submitCustomModel(modelId) {
1843
- // Clean up model ID
1844
  modelId = modelId.replace(/^https?:\/\/huggingface\.co\//, '').replace(/\/$/, '');
1845
  setPillState('loading');
1846
 
@@ -1860,7 +2142,6 @@ async function submitCustomModel(modelId) {
1860
  const task = data.pipeline_tag || 'unknown';
1861
  const downloads = data.downloads || 1;
1862
 
1863
- // Fetch avatar if new author
1864
  let avatarUrl = '';
1865
  if (author && avatarMap[author]) {
1866
  avatarUrl = avatarMap[author];
@@ -1874,7 +2155,6 @@ async function submitCustomModel(modelId) {
1874
  } catch (_) {}
1875
  }
1876
 
1877
- // If there's already a custom model, remove it first
1878
  if (customModelActive) removeCustomModel();
1879
 
1880
  insertCustomModel({
 
42
  text-decoration: none;
43
  }
44
 
45
+ /* ── Add Model Pill Button (top-right) ── */
46
  #addModelBtn {
47
  position: fixed;
48
+ top: 24px;
49
  right: 24px;
50
  z-index: 10;
51
  }
 
195
  @keyframes pillSpin {
196
  to { transform: rotate(360deg); }
197
  }
198
+
199
+ /* ── Model Dropdown (below pill) ── */
200
+ #modelDropdown {
201
+ position: absolute;
202
+ top: calc(100% + 8px);
203
+ right: 0;
204
+ width: 380px;
205
+ max-height: 420px;
206
+ background: #161513;
207
+ border: 1px solid rgba(246,244,239,0.1);
208
+ border-radius: 12px;
209
+ overflow: hidden;
210
+ display: none;
211
+ flex-direction: column;
212
+ box-shadow: 0 16px 48px rgba(0,0,0,0.35);
213
+ z-index: 20;
214
+ }
215
+ #modelDropdown.open {
216
+ display: flex;
217
+ }
218
+
219
+ .dropdown-search {
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 8px;
223
+ padding: 10px 14px;
224
+ border-bottom: 1px solid rgba(246,244,239,0.08);
225
+ }
226
+ .dropdown-search svg {
227
+ width: 16px;
228
+ height: 16px;
229
+ flex-shrink: 0;
230
+ opacity: 0.4;
231
+ }
232
+ .dropdown-search input {
233
+ flex: 1;
234
+ background: transparent;
235
+ border: none;
236
+ outline: none;
237
+ color: #F6F4EF;
238
+ font: 400 13px/1 'Inter', sans-serif;
239
+ caret-color: #E8820C;
240
+ }
241
+ .dropdown-search input::placeholder { color: rgba(246,244,239,0.3); }
242
+
243
+ .dropdown-content {
244
+ overflow-y: auto;
245
+ flex: 1;
246
+ padding: 4px 0;
247
+ }
248
+
249
+ .dropdown-section-header {
250
+ padding: 10px 14px 4px;
251
+ font: 500 11px/1 'Inter', sans-serif;
252
+ color: #E8820C;
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 5px;
256
+ text-transform: uppercase;
257
+ letter-spacing: 0.03em;
258
+ }
259
+
260
+ .dropdown-model {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 10px;
264
+ padding: 8px 14px;
265
+ cursor: pointer;
266
+ transition: background 0.1s;
267
+ color: rgba(246,244,239,0.85);
268
+ font: 400 13px/1 'Inter', sans-serif;
269
+ }
270
+ .dropdown-model:hover, .dropdown-model.selected {
271
+ background: rgba(246,244,239,0.07);
272
+ }
273
+ .dropdown-model img {
274
+ width: 24px;
275
+ height: 24px;
276
+ border-radius: 5px;
277
+ object-fit: cover;
278
+ background: rgba(246,244,239,0.08);
279
+ flex-shrink: 0;
280
+ }
281
+ .dropdown-avatar-placeholder {
282
+ width: 24px;
283
+ height: 24px;
284
+ border-radius: 5px;
285
+ background: rgba(246,244,239,0.06);
286
+ flex-shrink: 0;
287
+ }
288
+
289
+ .dropdown-loading {
290
+ padding: 24px;
291
+ text-align: center;
292
+ }
293
+ .dropdown-spinner {
294
+ display: inline-block;
295
+ width: 16px;
296
+ height: 16px;
297
+ border: 2px solid rgba(246,244,239,0.12);
298
+ border-top-color: #E8820C;
299
+ border-radius: 50%;
300
+ animation: pillSpin 0.6s linear infinite;
301
+ }
302
+ .dropdown-empty {
303
+ padding: 24px 14px;
304
+ color: rgba(246,244,239,0.35);
305
+ text-align: center;
306
+ font: 400 13px/1 'Inter', sans-serif;
307
+ }
308
  </style>
309
  </head>
310
  <body>
 
323
  <div class="active-wrap">
324
  <span class="orange-dot"></span>
325
  <span class="model-name"></span>
326
+ <button class="remove-btn">&times;</button>
327
+ </div>
328
+ </div>
329
+ <div id="modelDropdown">
330
+ <div class="dropdown-search">
331
+ <svg viewBox="0 0 18 18" fill="none" stroke="rgba(246,244,239,0.5)" stroke-width="2" stroke-linecap="round"><circle cx="7.5" cy="7.5" r="5.5"/><line x1="11.5" y1="11.5" x2="16" y2="16"/></svg>
332
+ <input type="text" id="dropdownSearchInput" placeholder="Search models..." autocomplete="off" />
333
  </div>
334
+ <div class="dropdown-content" id="dropdownContent"></div>
335
  </div>
336
  </div>
337
  <script>
 
429
  ];
430
 
431
  let models;
432
+ let allTrendingModels = [];
433
  try {
434
  // Fetch trending models, then pick the top 10 by downloads
435
  const res = await fetch('https://huggingface.co/api/models?sort=trendingScore&limit=50');
 
448
  });
449
  // Sort by downloads descending and take top 10
450
  all.sort((a, b) => b.downloads - a.downloads);
451
+ allTrendingModels = all.slice();
452
  models = all.slice(0, 10);
453
  } catch (e) {
454
  models = FALLBACK.map(m => {
455
  const author = m.id.includes('/') ? m.id.split('/')[0] : '';
456
  return { ...m, author: author, avatarUrl: '' };
457
  });
458
+ allTrendingModels = models.slice();
459
  }
460
 
461
+ // Fetch author avatars in parallel (non-blocking) β€” covers all 50 trending models
462
+ const uniqueAuthors = [...new Set(allTrendingModels.map(m => m.author).filter(Boolean))];
463
  const avatarMap = {};
464
  await Promise.all(uniqueAuthors.map(async (author) => {
465
  try {
 
473
  for (const m of models) {
474
  if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author];
475
  }
476
+ for (const m of allTrendingModels) {
477
+ if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author];
478
+ }
479
 
480
  // ── Mass Mapping ───────────────────────────────────────────
481
  const D = models.map(m => m.downloads);
 
1464
  return img;
1465
  }
1466
 
1467
+ // Load SVG text via XHR (works on file:// unlike fetch)
1468
+ function loadSVGText(url) {
1469
+ return new Promise((resolve, reject) => {
1470
+ const xhr = new XMLHttpRequest();
1471
+ xhr.open('GET', url, true);
1472
+ xhr.onload = () => xhr.status === 200 || xhr.status === 0 ? resolve(xhr.responseText) : reject();
1473
+ xhr.onerror = reject;
1474
+ xhr.send();
1475
+ });
1476
+ }
1477
+
1478
  // Preload all icon SVGs in both COLOR and ORANGE variants
1479
  async function preloadTaskIcons() {
1480
  const colors = [COLOR, ORANGE];
1481
  const entries = Object.entries(TASK_ICON_FILES);
1482
  await Promise.all(entries.map(async ([task, file]) => {
1483
  try {
1484
+ const svgText = await loadSVGText(ICON_ASSET_DIR + file);
1485
+ if (!svgText) return;
 
1486
  taskIconImgs[task] = {};
1487
  for (const c of colors) {
1488
  const img = buildColoredSVGImage(svgText, c, ICON_RENDER_SIZE);
 
1869
  cursorOnCanvas = false; // triggers spring snapback
1870
  });
1871
 
1872
+ // ── Custom Model: Pill Button + Dropdown Logic ──────────────
1873
  const addPill = document.getElementById('addPill');
1874
  const pillInput = addPill.querySelector('input[type="text"]');
1875
  const pillAddBtn = addPill.querySelector('.add-btn');
1876
  const pillError = addPill.querySelector('.error-msg');
1877
  const pillModelName = addPill.querySelector('.model-name');
1878
  const pillRemoveBtn = addPill.querySelector('.remove-btn');
1879
+ const dropdown = document.getElementById('modelDropdown');
1880
+ const dropdownInput = document.getElementById('dropdownSearchInput');
1881
+ const dropdownContent = document.getElementById('dropdownContent');
1882
 
1883
  // Pill states: 'idle' | 'expanded' | 'loading' | 'active'
1884
  let pillState = 'idle';
1885
  let pillAnim = null;
1886
+ let dropdownOpen = false;
1887
+ let searchTimer = null;
1888
+ let selectedIdx = -1;
1889
+ let currentItems = [];
1890
 
1891
  function animatePill(toState) {
 
1892
  const fromW = addPill.offsetWidth;
 
1893
  addPill.classList.remove('expanded', 'active', 'loading');
1894
  pillError.textContent = '';
1895
  pillError.style.display = 'none';
 
1903
  const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id;
1904
  pillModelName.textContent = shortName;
1905
  }
 
1906
  const toW = addPill.offsetWidth;
 
1907
  if (pillAnim) pillAnim.cancel();
1908
  if (fromW !== toW) {
1909
  pillAnim = addPill.animate(
 
1915
  }
1916
 
1917
  function setPillState(state) {
 
1918
  pillState = state;
1919
  animatePill(state);
1920
  if (state === 'expanded') {
1921
  setTimeout(() => pillInput.focus(), 80);
1922
+ openDropdown();
1923
+ } else {
1924
+ closeDropdown();
1925
+ }
1926
+ }
1927
+
1928
+ // ── Dropdown open/close ──
1929
+ function openDropdown() {
1930
+ dropdownOpen = true;
1931
+ dropdown.classList.add('open');
1932
+ dropdownInput.value = '';
1933
+ selectedIdx = -1;
1934
+ renderTrending();
1935
+ }
1936
+
1937
+ function closeDropdown() {
1938
+ dropdownOpen = false;
1939
+ dropdown.classList.remove('open');
1940
+ selectedIdx = -1;
1941
+ if (searchTimer) clearTimeout(searchTimer);
1942
+ }
1943
+
1944
+ // ── Dropdown rendering ──
1945
+ function modelCardHTML(m, idx) {
1946
+ const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id;
1947
+ const avatarSrc = m.avatarUrl || avatarMap[m.author] || '';
1948
+ const imgTag = avatarSrc
1949
+ ? '<img src="' + avatarSrc + '" alt="" />'
1950
+ : '<div class="dropdown-avatar-placeholder"></div>';
1951
+ return '<div class="dropdown-model" data-model-id="' + m.id + '" data-idx="' + idx + '">' + imgTag + '<span>' + shortName + '</span></div>';
1952
+ }
1953
+
1954
+ function attachCardListeners() {
1955
+ dropdownContent.querySelectorAll('.dropdown-model').forEach(function(el) {
1956
+ el.addEventListener('click', function(e) {
1957
+ e.stopPropagation();
1958
+ const modelId = el.dataset.modelId;
1959
+ setPillState('idle');
1960
+ submitCustomModel(modelId);
1961
+ });
1962
+ });
1963
+ }
1964
+
1965
+ function updateSelection() {
1966
+ const cards = dropdownContent.querySelectorAll('.dropdown-model');
1967
+ cards.forEach(function(el, i) { el.classList.toggle('selected', i === selectedIdx); });
1968
+ if (selectedIdx >= 0 && selectedIdx < cards.length) {
1969
+ cards[selectedIdx].scrollIntoView({ block: 'nearest' });
1970
+ }
1971
+ }
1972
+
1973
+ function renderTrending() {
1974
+ currentItems = allTrendingModels.slice(0, 20);
1975
+ let html = '<div class="dropdown-section-header">\uD83D\uDD25 Trending</div>';
1976
+ currentItems.forEach(function(m, i) { html += modelCardHTML(m, i); });
1977
+ dropdownContent.innerHTML = html;
1978
+ selectedIdx = -1;
1979
+ attachCardListeners();
1980
+ }
1981
+
1982
+ function renderSearchResults(query, items) {
1983
+ if (!items.length) {
1984
+ dropdownContent.innerHTML = '<div class="dropdown-empty">No models found</div>';
1985
+ currentItems = [];
1986
+ return;
1987
+ }
1988
+ const trendingIds = new Set(allTrendingModels.map(function(m) { return m.id; }));
1989
+ const q = query.toLowerCase();
1990
+ const trendingMatches = allTrendingModels.filter(function(m) { return m.id.toLowerCase().includes(q); });
1991
+ const others = items.filter(function(m) { return !trendingIds.has(m.id); });
1992
+
1993
+ let html = '';
1994
+ let idx = 0;
1995
+ if (trendingMatches.length) {
1996
+ html += '<div class="dropdown-section-header">\uD83D\uDD25 Trending</div>';
1997
+ trendingMatches.forEach(function(m) { html += modelCardHTML(m, idx++); });
1998
+ }
1999
+ if (others.length) {
2000
+ html += '<div class="dropdown-section-header" style="color:rgba(246,244,239,0.35)">Other models</div>';
2001
+ others.forEach(function(m) { html += modelCardHTML(m, idx++); });
2002
+ }
2003
+ currentItems = trendingMatches.concat(others);
2004
+ dropdownContent.innerHTML = html;
2005
+ selectedIdx = -1;
2006
+ attachCardListeners();
2007
+ }
2008
+
2009
+ async function searchModels(query) {
2010
+ if (!query.trim()) { renderTrending(); return; }
2011
+ dropdownContent.innerHTML = '<div class="dropdown-loading"><div class="dropdown-spinner"></div></div>';
2012
+ try {
2013
+ const res = await fetch('https://huggingface.co/api/models?search=' + encodeURIComponent(query) + '&sort=downloads&direction=-1&limit=20');
2014
+ if (!res.ok) throw new Error(res.status);
2015
+ const data = await res.json();
2016
+ const results = data.map(function(m) {
2017
+ const fullId = m.modelId || m.id;
2018
+ return {
2019
+ id: fullId,
2020
+ downloads: m.downloads || 0,
2021
+ task: m.pipeline_tag || 'unknown',
2022
+ author: fullId.includes('/') ? fullId.split('/')[0] : '',
2023
+ avatarUrl: ''
2024
+ };
2025
+ });
2026
+ const newAuthors = [...new Set(results.map(function(m) { return m.author; }).filter(function(a) { return a && !avatarMap[a]; }))];
2027
+ await Promise.all(newAuthors.map(async function(author) {
2028
+ try {
2029
+ const r = await fetch('https://huggingface.co/api/organizations/' + author + '/avatar');
2030
+ if (r.ok) { const j = await r.json(); if (j.avatarUrl) avatarMap[author] = j.avatarUrl; }
2031
+ } catch (_) {}
2032
+ }));
2033
+ results.forEach(function(m) { if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author]; });
2034
+ renderSearchResults(query, results);
2035
+ } catch (e) {
2036
+ dropdownContent.innerHTML = '<div class="dropdown-empty">Search failed. Try again.</div>';
2037
  }
2038
  }
2039
 
2040
+ // ── Pill event listeners ──
2041
+
2042
  // Idle β†’ click β†’ expand
2043
  addPill.addEventListener('click', (e) => {
2044
  if (pillState === 'idle') {
2045
  setPillState('expanded');
2046
  e.stopPropagation();
2047
  } else if (pillState === 'active') {
 
2048
  if (!e.target.classList.contains('remove-btn')) {
2049
  removeCustomModel();
2050
  setPillState('expanded');
 
2068
  if (val) submitCustomModel(val);
2069
  });
2070
 
2071
+ // Enter key in pill input β†’ submit typed model
2072
  pillInput.addEventListener('keydown', (e) => {
2073
  if (e.key === 'Enter') {
2074
  e.preventDefault();
 
2079
  }
2080
  });
2081
 
 
2082
  pillInput.addEventListener('click', (e) => e.stopPropagation());
2083
 
2084
+ // Dropdown search input
2085
+ dropdownInput.addEventListener('input', function() {
2086
+ if (searchTimer) clearTimeout(searchTimer);
2087
+ searchTimer = setTimeout(function() {
2088
+ searchModels(dropdownInput.value);
2089
+ }, 300);
2090
+ });
2091
+
2092
+ dropdownInput.addEventListener('click', (e) => e.stopPropagation());
2093
+
2094
+ // Keyboard navigation in dropdown search
2095
+ dropdownInput.addEventListener('keydown', function(e) {
2096
+ const cards = dropdownContent.querySelectorAll('.dropdown-model');
2097
+ if (e.key === 'ArrowDown') {
2098
+ e.preventDefault();
2099
+ selectedIdx = Math.min(selectedIdx + 1, cards.length - 1);
2100
+ updateSelection();
2101
+ } else if (e.key === 'ArrowUp') {
2102
+ e.preventDefault();
2103
+ selectedIdx = Math.max(selectedIdx - 1, -1);
2104
+ updateSelection();
2105
+ } else if (e.key === 'Enter') {
2106
+ e.preventDefault();
2107
+ if (selectedIdx >= 0 && selectedIdx < cards.length) {
2108
+ const modelId = cards[selectedIdx].dataset.modelId;
2109
+ setPillState('idle');
2110
+ submitCustomModel(modelId);
2111
+ }
2112
+ } else if (e.key === 'Escape') {
2113
+ setPillState(customModelActive ? 'active' : 'idle');
2114
+ }
2115
+ });
2116
+
2117
+ // Click outside β†’ collapse pill + close dropdown
2118
  document.addEventListener('click', (e) => {
2119
+ if (pillState === 'expanded' && !document.getElementById('addModelBtn').contains(e.target)) {
2120
  setPillState(customModelActive ? 'active' : 'idle');
2121
  }
2122
  });
2123
 
2124
  // ── submitCustomModel: fetch model data from HF API ─────────
2125
  async function submitCustomModel(modelId) {
 
2126
  modelId = modelId.replace(/^https?:\/\/huggingface\.co\//, '').replace(/\/$/, '');
2127
  setPillState('loading');
2128
 
 
2142
  const task = data.pipeline_tag || 'unknown';
2143
  const downloads = data.downloads || 1;
2144
 
 
2145
  let avatarUrl = '';
2146
  if (author && avatarMap[author]) {
2147
  avatarUrl = avatarMap[author];
 
2155
  } catch (_) {}
2156
  }
2157
 
 
2158
  if (customModelActive) removeCustomModel();
2159
 
2160
  insertCustomModel({