gabriel / musehub public
explore.ts typescript
380 lines 16.4 KB
Raw
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