/** * explore.ts — MuseHub explore page. * * Two modes: * browse — filter sidebar + SSR repo grid (HTMX fragments, unchanged) * search — live semantic/text search via /api/search/repos and * /api/search?q=...&mode=keyword for commits * * The search bar is the focal point. Typing anything ≥ 2 chars triggers a * debounced fetch; clearing returns to browse mode. */ // ── Repo result shape from /api/search/repos ────────────────────────────── interface RepoResult { repo_id: string; name: string | null; owner: string; slug: string; description: string | null; tags: string[]; commit_count: number; key_signature?: string | null; tempo_bpm?: number | null; } interface SearchReposResponse { query: string; semantic: boolean; repos: RepoResult[]; } // Commit group from /api/search (global cross-repo commit search) interface CommitMatch { commit_id: string; message: string; author: string; branch: string; } interface CommitGroup { repo_id: string; repo_name: string; owner: string; matches: CommitMatch[]; } interface CommitSearchResponse { groups: CommitGroup[]; total_repos: number; } // ── SVG helpers ────────────────────────────────────────────────────────────── const SVG_COMMIT = ``; // ── Entry point ─────────────────────────────────────────────────────────────── 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('ex-search-input') as HTMLInputElement | null; if (!navForm || !navInput || !heroInput) return; // Prevent the navbar form from navigating away 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 (existing HTMX chip/filter 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' }); }); }); document.querySelectorAll('[data-action="toggle-sidebar"]').forEach((btn) => { btn.addEventListener('click', () => { document.querySelector('.explore-sidebar')?.classList.toggle('open'); }); }); } // ── Semantic search mode ────────────────────────────────────────────────────── function setupSemanticSearch(): void { const searchInput = document.getElementById('ex-search-input') as HTMLInputElement | null; const searchClear = document.getElementById('ex-search-clear') as HTMLButtonElement | null; const searchSpinner = document.getElementById('ex-search-spinner') as HTMLElement | null; const searchField = document.getElementById('ex-search-field') as HTMLElement | null; const semanticResults = document.getElementById('ex-semantic-results') as HTMLElement | null; const browseLayout = document.getElementById('ex-browse-layout') as HTMLElement | null; const typeBar = document.getElementById('ex-type-bar') as HTMLElement | null; const semanticDot = document.getElementById('ex-semantic-indicator') as HTMLElement | null; if (!searchInput || !semanticResults) return; let debounceTimer: ReturnType | null = null; let currentQuery = ''; let currentType = 'repos'; let pendingAbort: AbortController | null = null; // ── Example query chips ────────────────────────────────────────────────── document.querySelectorAll('[data-query]').forEach((chip) => { chip.addEventListener('click', () => { searchInput.value = chip.dataset.query ?? ''; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }); }); // ── Search type toggle ─────────────────────────────────────────────────── document.querySelectorAll('[data-search-type]').forEach((pill) => { pill.addEventListener('click', () => { document.querySelectorAll('[data-search-type]').forEach((p) => p.classList.remove('ex-type-pill--active'), ); pill.classList.add('ex-type-pill--active'); currentType = pill.dataset.searchType ?? 'repos'; if (currentQuery.length >= 2) scheduleSearch(currentQuery); }); }); // ── Clear button ───────────────────────────────────────────────────────── searchClear?.addEventListener('click', () => { searchInput.value = ''; clearSearch(); }); // ── Input → debounce ───────────────────────────────────────────────────── 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); }); // ── Keyboard shortcuts ──────────────────────────────────────────────────── 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 (semanticDot) semanticDot.style.display = 'none'; if (semanticResults) semanticResults.style.display = 'none'; if (browseLayout) browseLayout.style.display = ''; if (searchField) searchField.classList.remove('ex-search-field--active'); } // ── Main search fetch ───────────────────────────────────────────────────── async function performSearch(q: string, type: string): Promise { if (pendingAbort) pendingAbort.abort(); pendingAbort = new AbortController(); setLoading(true); if (searchField) searchField.classList.add('ex-search-field--active'); try { let data: unknown; if (type === 'repos') { data = await window.apiFetch( `/search/repos?q=${encodeURIComponent(q)}&limit=20`, ); } else { // Commits: use global search endpoint (keyword mode) data = await window.apiFetch( `/search?q=${encodeURIComponent(q)}&mode=keyword&limit=10`, ); } renderResults(data, q, type); } catch (err) { if ((err as Error).name === 'AbortError') return; renderError(err as Error); } finally { setLoading(false); pendingAbort = null; } } function setLoading(on: boolean): void { if (searchSpinner) searchSpinner.style.display = on ? 'flex' : 'none'; if (browseLayout && on) browseLayout.style.opacity = '0.3'; if (browseLayout && !on) browseLayout.style.opacity = ''; } // ── Result rendering ────────────────────────────────────────────────────── function renderResults(data: unknown, q: string, type: string): void { if (!semanticResults) return; if (browseLayout) { browseLayout.style.display = 'none'; browseLayout.style.opacity = ''; } semanticResults.style.display = 'block'; const html = type === 'repos' ? renderRepoResults(data as SearchReposResponse, q) : renderCommitResults(data as CommitSearchResponse, q); semanticResults.innerHTML = html; } function renderRepoResults(data: SearchReposResponse, q: string): string { const repos = data.repos ?? []; const isSem = Boolean(data.semantic); if (semanticDot) semanticDot.style.display = isSem ? 'flex' : 'none'; const methodBadge = isSem ? `vector search · ℝ¹⁵³⁶` : `text search`; if (!repos.length) { return `
⌖

No repos found for "${window.escHtml(q)}"

Try different terms — or browse all repositories

`; } const header = `
${repos.length} result${repos.length !== 1 ? 's' : ''} ${methodBadge} "${window.escHtml(q)}"
`; const cards = repos.map((repo, idx) => { const href = `/${window.escHtml(repo.owner)}/${window.escHtml(repo.slug)}`; const name = repo.name || repo.slug; const desc = repo.description || ''; const tags = repo.tags ?? []; // Visual rank: first result gets full bar, last ~25% const total = Math.max(repos.length - 1, 1); const rankPct = isSem ? Math.round(100 - (idx / total) * 75) : 0; const tagHtml = tags.slice(0, 4) .map((t) => `${window.escHtml(String(t))}`) .join(''); const musePips = [ repo.key_signature ? `♩ ${window.escHtml(repo.key_signature)}` : '', repo.tempo_bpm ? `♩ ${repo.tempo_bpm} bpm` : '', ].filter(Boolean).join(''); return `
${window.escHtml(repo.owner)}/${window.escHtml(repo.slug)} ${isSem ? `${rankPct}%` : ''}
${desc ? `

${window.escHtml(desc.length > 120 ? desc.slice(0, 117) + '…' : desc)}

` : ''} ${tagHtml ? `
${tagHtml}
` : ''}
`; }).join(''); return header + `
${cards}
`; } function renderCommitResults(data: CommitSearchResponse, q: string): string { if (semanticDot) semanticDot.style.display = 'none'; const groups = data.groups ?? []; if (!groups.length) { return `
⌖

No commits found for "${window.escHtml(q)}"

Try different terms or switch to Repos search above

`; } const totalMatches = groups.reduce((s, g) => s + g.matches.length, 0); const header = `
${totalMatches} commit match${totalMatches !== 1 ? 'es' : ''} across ${groups.length} repo${groups.length !== 1 ? 's' : ''} keyword search "${window.escHtml(q)}"
`; const items = groups.map((group) => { const repoHref = `/${window.escHtml(group.owner)}/${window.escHtml(group.repo_name)}`; const matchRows = group.matches.slice(0, 5).map((m) => { const cHref = `${repoHref}/commits/${window.escHtml(m.commit_id)}`; // Extract conventional commit prefix for coloring const ct = (m.message || '').match(/^(\w+)[\(!:]/)?.[1]?.toLowerCase() ?? ''; const ctCol: Record = { feat:'#3fb950', fix:'#f85149', refactor:'#bc8cff', docs:'#6e96c9', chore:'#6e7681', perf:'#f0883e', }; const ctStyle = ct && ctCol[ct] ? `color:${ctCol[ct]}` : ''; return ` ${window.escHtml(m.commit_id.slice(0, 8))} ${window.escHtml(m.message.length > 90 ? m.message.slice(0, 87) + '…' : m.message)} ${window.escHtml(m.author)} `; }).join(''); return ``; }).join(''); return header + `
${items}
`; } function renderError(err: Error): void { if (!semanticResults) return; if (browseLayout) { browseLayout.style.display = ''; browseLayout.style.opacity = ''; } semanticResults.innerHTML = `
⚠

Search unavailable

${window.escHtml(err.message)}

`; semanticResults.style.display = 'block'; } }