/** * explore.ts — MuseHub explore page behaviour. * * Two modes: * browse — filter sidebar + SSR repo grid (HTMX fragments, unchanged) * search — live semantic/text search * * All HTML is server-rendered via /explore/search?q=...&type=... * This file owns only: input events, debounce, show/hide state, fetch. */ export function initExplore(): void { setupBrowseMode(); setupSemanticSearch(); wireNavbarSearch(); } // ── Wire navbar search → hero search on explore page ───────────────────────── function wireNavbarSearch(): void { const navForm = document.querySelector('.navbar-search-form'); const navInput = document.querySelector('.navbar-search-input'); const heroInput = document.getElementById('search-input') as HTMLInputElement | null; if (!navForm || !navInput || !heroInput) return; navForm.addEventListener('submit', (e) => { e.preventDefault(); const q = navInput.value.trim(); if (q) heroInput.value = q; heroInput.focus(); heroInput.dispatchEvent(new Event('input', { bubbles: true })); navInput.value = ''; }); navInput.addEventListener('focus', () => { heroInput.focus(); navInput.blur(); }); } // ── Browse mode (filter sidebar + HTMX chip behaviour) ─────────────────────── function setupBrowseMode(): void { const filterForm = document.getElementById('filter-form') as HTMLFormElement | null; filterForm?.addEventListener('submit', function () { Array.from(this.elements).forEach((el) => { const input = el as HTMLInputElement | HTMLSelectElement; if ((input.tagName === 'SELECT' || input.tagName === 'INPUT') && input.value === '') { input.disabled = true; } }); }); document.querySelectorAll('[data-autosubmit]').forEach((el) => { el.addEventListener('change', () => (el.closest('form') as HTMLFormElement)?.requestSubmit()); }); document.querySelectorAll('[data-filter][data-value]').forEach((chip) => { chip.addEventListener('click', (evt) => { evt.preventDefault(); const filterName = chip.dataset.filter ?? ''; const value = chip.dataset.value ?? ''; const params = new URLSearchParams(window.location.search); const current = params.getAll(filterName); if (current.includes(value)) { params.delete(filterName); current.filter((v) => v !== value).forEach((v) => params.append(filterName, v)); chip.classList.remove('active'); } else { params.append(filterName, value); chip.classList.add('active'); } const url = '/explore?' + params.toString(); history.pushState({}, '', url); const htmx = (window as unknown as Record).htmx as | { ajax: (m: string, u: string, o: Record) => void } | undefined; htmx?.ajax('GET', url, { target: '#repo-grid', swap: 'innerHTML' }); }); }); } // ── Semantic search mode ────────────────────────────────────────────────────── function setupSemanticSearch(): void { const searchInput = document.getElementById('search-input') as HTMLInputElement | null; const searchClear = document.getElementById('search-clear') as HTMLButtonElement | null; const searchSpinner = document.getElementById('search-spinner') as HTMLElement | null; const searchField = document.getElementById('search-field') as HTMLElement | null; const searchResults = document.getElementById('search-results') as HTMLElement | null; const browseLayout = document.getElementById('browse-layout') as HTMLElement | null; const typeBar = document.getElementById('search-type-bar') as HTMLElement | null; if (!searchInput || !searchResults) return; let debounceTimer: ReturnType | null = null; let currentQuery = ''; let currentType = 'repos'; let pendingAbort: AbortController | null = null; // Type pill toggle document.querySelectorAll('[data-search-type]').forEach((pill) => { pill.addEventListener('click', () => { document.querySelectorAll('[data-search-type]').forEach((p) => p.classList.remove('search-type-pill--active'), ); pill.classList.add('search-type-pill--active'); currentType = pill.dataset.searchType ?? 'repos'; if (currentQuery.length >= 2) scheduleSearch(currentQuery); }); }); searchClear?.addEventListener('click', () => { searchInput.value = ''; clearSearch(); }); searchInput.addEventListener('input', () => { currentQuery = searchInput.value.trim(); if (debounceTimer) clearTimeout(debounceTimer); if (!currentQuery) { clearSearch(); return; } if (searchClear) searchClear.style.display = 'flex'; if (typeBar) typeBar.style.display = 'flex'; scheduleSearch(currentQuery); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; clearSearch(); } }); function scheduleSearch(q: string): void { debounceTimer = setTimeout(() => performSearch(q, currentType), 300); } function clearSearch(): void { if (pendingAbort) { pendingAbort.abort(); pendingAbort = null; } currentQuery = ''; if (searchClear) searchClear.style.display = 'none'; if (typeBar) typeBar.style.display = 'none'; if (searchResults) searchResults.style.display = 'none'; if (browseLayout) browseLayout.style.display = ''; if (searchField) searchField.classList.remove('search-field--active'); } async function performSearch(q: string, type: string): Promise { if (pendingAbort) pendingAbort.abort(); pendingAbort = new AbortController(); if (searchField) searchField.classList.add('search-field--active'); if (searchSpinner) searchSpinner.style.display = 'flex'; if (browseLayout) browseLayout.style.opacity = '0.3'; try { const url = `/explore/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`; const resp = await fetch(url, { signal: pendingAbort.signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const html = await resp.text(); if (browseLayout) { browseLayout.style.display = 'none'; browseLayout.style.opacity = ''; } searchResults.style.display = 'block'; searchResults.innerHTML = html; } catch (err) { if ((err as Error).name === 'AbortError') return; if (browseLayout) { browseLayout.style.display = ''; browseLayout.style.opacity = ''; } searchResults.innerHTML = `

Search unavailable

${String((err as Error).message ?? err)}

`; searchResults.style.display = 'block'; } finally { if (searchSpinner) searchSpinner.style.display = 'none'; if (browseLayout && browseLayout.style.opacity) browseLayout.style.opacity = ''; pendingAbort = null; } } }