explore.ts
typescript
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠ breaking
156 days ago
| 1 | /** |
| 2 | * explore.ts — MuseHub explore page. |
| 3 | * |
| 4 | * Two modes: |
| 5 | * browse — filter sidebar + SSR repo grid (HTMX fragments, unchanged) |
| 6 | * search — live semantic/text search via /api/search/repos and |
| 7 | * /api/search?q=...&mode=keyword for commits |
| 8 | * |
| 9 | * The search bar is the focal point. Typing anything ≥ 2 chars triggers a |
| 10 | * debounced fetch; clearing returns to browse mode. |
| 11 | */ |
| 12 | |
| 13 | // ── Repo result shape from /api/search/repos ────────────────────────────── |
| 14 | |
| 15 | interface RepoResult { |
| 16 | repo_id: string; |
| 17 | name: string | null; |
| 18 | owner: string; |
| 19 | slug: string; |
| 20 | description: string | null; |
| 21 | tags: string[]; |
| 22 | commit_count: number; |
| 23 | key_signature?: string | null; |
| 24 | tempo_bpm?: number | null; |
| 25 | } |
| 26 | |
| 27 | interface SearchReposResponse { |
| 28 | query: string; |
| 29 | semantic: boolean; |
| 30 | repos: RepoResult[]; |
| 31 | } |
| 32 | |
| 33 | // Commit group from /api/search (global cross-repo commit search) |
| 34 | interface CommitMatch { |
| 35 | commit_id: string; |
| 36 | message: string; |
| 37 | author: string; |
| 38 | branch: string; |
| 39 | } |
| 40 | interface CommitGroup { |
| 41 | repo_id: string; |
| 42 | repo_name: string; |
| 43 | owner: string; |
| 44 | matches: CommitMatch[]; |
| 45 | } |
| 46 | interface CommitSearchResponse { |
| 47 | groups: CommitGroup[]; |
| 48 | total_repos: number; |
| 49 | } |
| 50 | |
| 51 | // ── SVG helpers ────────────────────────────────────────────────────────────── |
| 52 | |
| 53 | const SVG_COMMIT = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/></svg>`; |
| 54 | |
| 55 | // ── Entry point ─────────────────────────────────────────────────────────────── |
| 56 | |
| 57 | export function initExplore(): void { |
| 58 | setupBrowseMode(); |
| 59 | setupSemanticSearch(); |
| 60 | wireNavbarSearch(); |
| 61 | } |
| 62 | |
| 63 | // ── Wire navbar search → hero search on explore page ───────────────────────── |
| 64 | |
| 65 | function wireNavbarSearch(): void { |
| 66 | const navForm = document.querySelector<HTMLFormElement>('.navbar-search-form'); |
| 67 | const navInput = document.querySelector<HTMLInputElement>('.navbar-search-input'); |
| 68 | const heroInput = document.getElementById('ex-search-input') as HTMLInputElement | null; |
| 69 | if (!navForm || !navInput || !heroInput) return; |
| 70 | |
| 71 | // Prevent the navbar form from navigating away |
| 72 | navForm.addEventListener('submit', (e) => { |
| 73 | e.preventDefault(); |
| 74 | const q = navInput.value.trim(); |
| 75 | if (q) heroInput.value = q; |
| 76 | heroInput.focus(); |
| 77 | heroInput.dispatchEvent(new Event('input', { bubbles: true })); |
| 78 | navInput.value = ''; |
| 79 | }); |
| 80 | |
| 81 | navInput.addEventListener('focus', () => { |
| 82 | heroInput.focus(); |
| 83 | navInput.blur(); |
| 84 | }); |
| 85 | } |
| 86 | |
| 87 | // ── Browse mode (existing HTMX chip/filter behaviour) ──────────────────────── |
| 88 | |
| 89 | function setupBrowseMode(): void { |
| 90 | const filterForm = document.getElementById('filter-form') as HTMLFormElement | null; |
| 91 | filterForm?.addEventListener('submit', function () { |
| 92 | Array.from(this.elements).forEach((el) => { |
| 93 | const input = el as HTMLInputElement | HTMLSelectElement; |
| 94 | if ((input.tagName === 'SELECT' || input.tagName === 'INPUT') && input.value === '') { |
| 95 | input.disabled = true; |
| 96 | } |
| 97 | }); |
| 98 | }); |
| 99 | |
| 100 | document.querySelectorAll<HTMLElement>('[data-autosubmit]').forEach((el) => { |
| 101 | el.addEventListener('change', () => (el.closest('form') as HTMLFormElement)?.requestSubmit()); |
| 102 | }); |
| 103 | |
| 104 | document.querySelectorAll<HTMLAnchorElement>('[data-filter][data-value]').forEach((chip) => { |
| 105 | chip.addEventListener('click', (evt) => { |
| 106 | evt.preventDefault(); |
| 107 | const filterName = chip.dataset.filter ?? ''; |
| 108 | const value = chip.dataset.value ?? ''; |
| 109 | const params = new URLSearchParams(window.location.search); |
| 110 | const current = params.getAll(filterName); |
| 111 | |
| 112 | if (current.includes(value)) { |
| 113 | params.delete(filterName); |
| 114 | current.filter((v) => v !== value).forEach((v) => params.append(filterName, v)); |
| 115 | chip.classList.remove('active'); |
| 116 | } else { |
| 117 | params.append(filterName, value); |
| 118 | chip.classList.add('active'); |
| 119 | } |
| 120 | |
| 121 | const url = '/explore?' + params.toString(); |
| 122 | history.pushState({}, '', url); |
| 123 | |
| 124 | const htmx = (window as unknown as Record<string, unknown>).htmx as |
| 125 | | { ajax: (m: string, u: string, o: Record<string, unknown>) => void } |
| 126 | | undefined; |
| 127 | htmx?.ajax('GET', url, { target: '#repo-grid', swap: 'innerHTML' }); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | document.querySelectorAll<HTMLElement>('[data-action="toggle-sidebar"]').forEach((btn) => { |
| 132 | btn.addEventListener('click', () => { |
| 133 | document.querySelector('.explore-sidebar')?.classList.toggle('open'); |
| 134 | }); |
| 135 | }); |
| 136 | } |
| 137 | |
| 138 | // ── Semantic search mode ────────────────────────────────────────────────────── |
| 139 | |
| 140 | function setupSemanticSearch(): void { |
| 141 | const searchInput = document.getElementById('ex-search-input') as HTMLInputElement | null; |
| 142 | const searchClear = document.getElementById('ex-search-clear') as HTMLButtonElement | null; |
| 143 | const searchSpinner = document.getElementById('ex-search-spinner') as HTMLElement | null; |
| 144 | const searchField = document.getElementById('ex-search-field') as HTMLElement | null; |
| 145 | const semanticResults = document.getElementById('ex-semantic-results') as HTMLElement | null; |
| 146 | const browseLayout = document.getElementById('ex-browse-layout') as HTMLElement | null; |
| 147 | const typeBar = document.getElementById('ex-type-bar') as HTMLElement | null; |
| 148 | const semanticDot = document.getElementById('ex-semantic-indicator') as HTMLElement | null; |
| 149 | |
| 150 | if (!searchInput || !semanticResults) return; |
| 151 | |
| 152 | let debounceTimer: ReturnType<typeof setTimeout> | null = null; |
| 153 | let currentQuery = ''; |
| 154 | let currentType = 'repos'; |
| 155 | let pendingAbort: AbortController | null = null; |
| 156 | |
| 157 | // ── Example query chips ────────────────────────────────────────────────── |
| 158 | document.querySelectorAll<HTMLButtonElement>('[data-query]').forEach((chip) => { |
| 159 | chip.addEventListener('click', () => { |
| 160 | searchInput.value = chip.dataset.query ?? ''; |
| 161 | searchInput.dispatchEvent(new Event('input')); |
| 162 | searchInput.focus(); |
| 163 | }); |
| 164 | }); |
| 165 | |
| 166 | // ── Search type toggle ─────────────────────────────────────────────────── |
| 167 | document.querySelectorAll<HTMLButtonElement>('[data-search-type]').forEach((pill) => { |
| 168 | pill.addEventListener('click', () => { |
| 169 | document.querySelectorAll('[data-search-type]').forEach((p) => |
| 170 | p.classList.remove('ex-type-pill--active'), |
| 171 | ); |
| 172 | pill.classList.add('ex-type-pill--active'); |
| 173 | currentType = pill.dataset.searchType ?? 'repos'; |
| 174 | if (currentQuery.length >= 2) scheduleSearch(currentQuery); |
| 175 | }); |
| 176 | }); |
| 177 | |
| 178 | // ── Clear button ───────────────────────────────────────────────────────── |
| 179 | searchClear?.addEventListener('click', () => { |
| 180 | searchInput.value = ''; |
| 181 | clearSearch(); |
| 182 | }); |
| 183 | |
| 184 | // ── Input → debounce ───────────────────────────────────────────────────── |
| 185 | searchInput.addEventListener('input', () => { |
| 186 | currentQuery = searchInput.value.trim(); |
| 187 | if (debounceTimer) clearTimeout(debounceTimer); |
| 188 | |
| 189 | if (!currentQuery) { clearSearch(); return; } |
| 190 | if (searchClear) searchClear.style.display = 'flex'; |
| 191 | if (typeBar) typeBar.style.display = 'flex'; |
| 192 | scheduleSearch(currentQuery); |
| 193 | }); |
| 194 | |
| 195 | // ── Keyboard shortcuts ──────────────────────────────────────────────────── |
| 196 | searchInput.addEventListener('keydown', (e) => { |
| 197 | if (e.key === 'Escape') { searchInput.value = ''; clearSearch(); } |
| 198 | }); |
| 199 | |
| 200 | function scheduleSearch(q: string): void { |
| 201 | debounceTimer = setTimeout(() => performSearch(q, currentType), 300); |
| 202 | } |
| 203 | |
| 204 | function clearSearch(): void { |
| 205 | if (pendingAbort) { pendingAbort.abort(); pendingAbort = null; } |
| 206 | currentQuery = ''; |
| 207 | if (searchClear) searchClear.style.display = 'none'; |
| 208 | if (typeBar) typeBar.style.display = 'none'; |
| 209 | if (semanticDot) semanticDot.style.display = 'none'; |
| 210 | if (semanticResults) semanticResults.style.display = 'none'; |
| 211 | if (browseLayout) browseLayout.style.display = ''; |
| 212 | if (searchField) searchField.classList.remove('ex-search-field--active'); |
| 213 | } |
| 214 | |
| 215 | // ── Main search fetch ───────────────────────────────────────────────────── |
| 216 | async function performSearch(q: string, type: string): Promise<void> { |
| 217 | if (pendingAbort) pendingAbort.abort(); |
| 218 | pendingAbort = new AbortController(); |
| 219 | |
| 220 | setLoading(true); |
| 221 | if (searchField) searchField.classList.add('ex-search-field--active'); |
| 222 | |
| 223 | try { |
| 224 | let data: unknown; |
| 225 | if (type === 'repos') { |
| 226 | data = await window.apiFetch( |
| 227 | `/search/repos?q=${encodeURIComponent(q)}&limit=20`, |
| 228 | ); |
| 229 | } else { |
| 230 | // Commits: use global search endpoint (keyword mode) |
| 231 | data = await window.apiFetch( |
| 232 | `/search?q=${encodeURIComponent(q)}&mode=keyword&limit=10`, |
| 233 | ); |
| 234 | } |
| 235 | renderResults(data, q, type); |
| 236 | } catch (err) { |
| 237 | if ((err as Error).name === 'AbortError') return; |
| 238 | renderError(err as Error); |
| 239 | } finally { |
| 240 | setLoading(false); |
| 241 | pendingAbort = null; |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | function setLoading(on: boolean): void { |
| 246 | if (searchSpinner) searchSpinner.style.display = on ? 'flex' : 'none'; |
| 247 | if (browseLayout && on) browseLayout.style.opacity = '0.3'; |
| 248 | if (browseLayout && !on) browseLayout.style.opacity = ''; |
| 249 | } |
| 250 | |
| 251 | // ── Result rendering ────────────────────────────────────────────────────── |
| 252 | function renderResults(data: unknown, q: string, type: string): void { |
| 253 | if (!semanticResults) return; |
| 254 | |
| 255 | if (browseLayout) { browseLayout.style.display = 'none'; browseLayout.style.opacity = ''; } |
| 256 | semanticResults.style.display = 'block'; |
| 257 | |
| 258 | const html = type === 'repos' |
| 259 | ? renderRepoResults(data as SearchReposResponse, q) |
| 260 | : renderCommitResults(data as CommitSearchResponse, q); |
| 261 | |
| 262 | semanticResults.innerHTML = html; |
| 263 | } |
| 264 | |
| 265 | function renderRepoResults(data: SearchReposResponse, q: string): string { |
| 266 | const repos = data.repos ?? []; |
| 267 | const isSem = Boolean(data.semantic); |
| 268 | |
| 269 | if (semanticDot) semanticDot.style.display = isSem ? 'flex' : 'none'; |
| 270 | |
| 271 | const methodBadge = isSem |
| 272 | ? `<span class="ex-sr-method ex-sr-method--semantic">vector search · ℝ¹⁵³⁶</span>` |
| 273 | : `<span class="ex-sr-method ex-sr-method--text">text search</span>`; |
| 274 | |
| 275 | if (!repos.length) { |
| 276 | return `<div class="ex-sr-empty"> |
| 277 | <div class="ex-sr-empty-icon">⌖</div> |
| 278 | <p class="ex-sr-empty-title">No repos found for <em>"${window.escHtml(q)}"</em></p> |
| 279 | <p class="ex-sr-empty-sub">Try different terms — or <a href="/explore">browse all repositories</a></p> |
| 280 | </div>`; |
| 281 | } |
| 282 | |
| 283 | const header = `<div class="ex-sr-header"> |
| 284 | <span class="ex-sr-count">${repos.length} result${repos.length !== 1 ? 's' : ''}</span> |
| 285 | ${methodBadge} |
| 286 | <span class="ex-sr-query">"${window.escHtml(q)}"</span> |
| 287 | </div>`; |
| 288 | |
| 289 | const cards = repos.map((repo, idx) => { |
| 290 | const href = `/${window.escHtml(repo.owner)}/${window.escHtml(repo.slug)}`; |
| 291 | const name = repo.name || repo.slug; |
| 292 | const desc = repo.description || ''; |
| 293 | const tags = repo.tags ?? []; |
| 294 | // Visual rank: first result gets full bar, last ~25% |
| 295 | const total = Math.max(repos.length - 1, 1); |
| 296 | const rankPct = isSem ? Math.round(100 - (idx / total) * 75) : 0; |
| 297 | |
| 298 | const tagHtml = tags.slice(0, 4) |
| 299 | .map((t) => `<span class="tag-pill">${window.escHtml(String(t))}</span>`) |
| 300 | .join(''); |
| 301 | |
| 302 | const musePips = [ |
| 303 | repo.key_signature ? `<span class="ex-sr-pip ex-sr-pip--key">♩ ${window.escHtml(repo.key_signature)}</span>` : '', |
| 304 | repo.tempo_bpm ? `<span class="ex-sr-pip ex-sr-pip--tempo">♩ ${repo.tempo_bpm} bpm</span>` : '', |
| 305 | ].filter(Boolean).join(''); |
| 306 | |
| 307 | return `<a href="${href}" class="repo-card"> |
| 308 | <div class="repo-card-header"> |
| 309 | <span class="repo-card-name">${window.escHtml(repo.owner)}<span class="repo-card-sep">/</span>${window.escHtml(repo.slug)}</span> |
| 310 | ${isSem ? `<span class="ex-sr-score">${rankPct}%</span>` : ''} |
| 311 | </div> |
| 312 | ${desc ? `<p class="repo-card-desc">${window.escHtml(desc.length > 120 ? desc.slice(0, 117) + '…' : desc)}</p>` : ''} |
| 313 | ${tagHtml ? `<div class="repo-card-pills">${tagHtml}</div>` : ''} |
| 314 | <div class="repo-card-footer"> |
| 315 | <span class="repo-card-stat">${SVG_COMMIT} ${repo.commit_count} commits</span> |
| 316 | ${isSem ? `<span class="ex-sr-method ex-sr-method--semantic" style="margin-left:auto">vector</span>` : ''} |
| 317 | </div> |
| 318 | </a>`; |
| 319 | }).join(''); |
| 320 | |
| 321 | return header + `<div class="ex-sr-list">${cards}</div>`; |
| 322 | } |
| 323 | |
| 324 | function renderCommitResults(data: CommitSearchResponse, q: string): string { |
| 325 | if (semanticDot) semanticDot.style.display = 'none'; |
| 326 | |
| 327 | const groups = data.groups ?? []; |
| 328 | if (!groups.length) { |
| 329 | return `<div class="ex-sr-empty"> |
| 330 | <div class="ex-sr-empty-icon">⌖</div> |
| 331 | <p class="ex-sr-empty-title">No commits found for <em>"${window.escHtml(q)}"</em></p> |
| 332 | <p class="ex-sr-empty-sub">Try different terms or switch to <strong>Repos</strong> search above</p> |
| 333 | </div>`; |
| 334 | } |
| 335 | |
| 336 | const totalMatches = groups.reduce((s, g) => s + g.matches.length, 0); |
| 337 | const header = `<div class="ex-sr-header"> |
| 338 | <span class="ex-sr-count">${totalMatches} commit match${totalMatches !== 1 ? 'es' : ''} across ${groups.length} repo${groups.length !== 1 ? 's' : ''}</span> |
| 339 | <span class="ex-sr-method ex-sr-method--text">keyword search</span> |
| 340 | <span class="ex-sr-query">"${window.escHtml(q)}"</span> |
| 341 | </div>`; |
| 342 | |
| 343 | const items = groups.map((group) => { |
| 344 | const repoHref = `/${window.escHtml(group.owner)}/${window.escHtml(group.repo_name)}`; |
| 345 | const matchRows = group.matches.slice(0, 5).map((m) => { |
| 346 | const cHref = `${repoHref}/commits/${window.escHtml(m.commit_id)}`; |
| 347 | // Extract conventional commit prefix for coloring |
| 348 | const ct = (m.message || '').match(/^(\w+)[\(!:]/)?.[1]?.toLowerCase() ?? ''; |
| 349 | const ctCol: Record<string, string> = { |
| 350 | feat:'#3fb950', fix:'#f85149', refactor:'#bc8cff', |
| 351 | docs:'#6e96c9', chore:'#6e7681', perf:'#f0883e', |
| 352 | }; |
| 353 | const ctStyle = ct && ctCol[ct] ? `color:${ctCol[ct]}` : ''; |
| 354 | return `<a href="${cHref}" class="ex-sr-commit"> |
| 355 | <code class="ex-sr-commit-sha">${window.escHtml(m.commit_id.slice(0, 8))}</code> |
| 356 | <span class="ex-sr-commit-msg" style="${ctStyle}">${window.escHtml(m.message.length > 90 ? m.message.slice(0, 87) + '…' : m.message)}</span> |
| 357 | <span class="ex-sr-commit-author">${window.escHtml(m.author)}</span> |
| 358 | </a>`; |
| 359 | }).join(''); |
| 360 | |
| 361 | return `<div class="ex-sr-commit-group"> |
| 362 | <a href="${repoHref}" class="ex-sr-commit-group-repo">${window.escHtml(group.owner)}/${window.escHtml(group.repo_name)}</a> |
| 363 | <div class="ex-sr-commit-rows">${matchRows}</div> |
| 364 | </div>`; |
| 365 | }).join(''); |
| 366 | |
| 367 | return header + `<div class="ex-sr-commit-list">${items}</div>`; |
| 368 | } |
| 369 | |
| 370 | function renderError(err: Error): void { |
| 371 | if (!semanticResults) return; |
| 372 | if (browseLayout) { browseLayout.style.display = ''; browseLayout.style.opacity = ''; } |
| 373 | semanticResults.innerHTML = `<div class="ex-sr-empty"> |
| 374 | <div class="ex-sr-empty-icon">⚠</div> |
| 375 | <p class="ex-sr-empty-title">Search unavailable</p> |
| 376 | <p class="ex-sr-empty-sub">${window.escHtml(err.message)}</p> |
| 377 | </div>`; |
| 378 | semanticResults.style.display = 'block'; |
| 379 | } |
| 380 | } |
File History
1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠
156 days ago