gabriel / musehub public
explore.ts typescript
181 lines 7.2 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 /**
2 * explore.ts — MuseHub explore page behaviour.
3 *
4 * Two modes:
5 * browse — filter sidebar + SSR repo grid (HTMX fragments, unchanged)
6 * search — live semantic/text search
7 *
8 * All HTML is server-rendered via /explore/search?q=...&type=...
9 * This file owns only: input events, debounce, show/hide state, fetch.
10 */
11
12 export function initExplore(): void {
13 setupBrowseMode();
14 setupSemanticSearch();
15 wireNavbarSearch();
16 }
17
18 // ── Wire navbar search → hero search on explore page ─────────────────────────
19
20 function wireNavbarSearch(): void {
21 const navForm = document.querySelector<HTMLFormElement>('.navbar-search-form');
22 const navInput = document.querySelector<HTMLInputElement>('.navbar-search-input');
23 const heroInput = document.getElementById('search-input') as HTMLInputElement | null;
24 if (!navForm || !navInput || !heroInput) return;
25
26 navForm.addEventListener('submit', (e) => {
27 e.preventDefault();
28 const q = navInput.value.trim();
29 if (q) heroInput.value = q;
30 heroInput.focus();
31 heroInput.dispatchEvent(new Event('input', { bubbles: true }));
32 navInput.value = '';
33 });
34
35 navInput.addEventListener('focus', () => {
36 heroInput.focus();
37 navInput.blur();
38 });
39 }
40
41 // ── Browse mode (filter sidebar + HTMX chip behaviour) ───────────────────────
42
43 function setupBrowseMode(): void {
44 const filterForm = document.getElementById('filter-form') as HTMLFormElement | null;
45 filterForm?.addEventListener('submit', function () {
46 Array.from(this.elements).forEach((el) => {
47 const input = el as HTMLInputElement | HTMLSelectElement;
48 if ((input.tagName === 'SELECT' || input.tagName === 'INPUT') && input.value === '') {
49 input.disabled = true;
50 }
51 });
52 });
53
54 document.querySelectorAll<HTMLElement>('[data-autosubmit]').forEach((el) => {
55 el.addEventListener('change', () => (el.closest('form') as HTMLFormElement)?.requestSubmit());
56 });
57
58 document.querySelectorAll<HTMLAnchorElement>('[data-filter][data-value]').forEach((chip) => {
59 chip.addEventListener('click', (evt) => {
60 evt.preventDefault();
61 const filterName = chip.dataset.filter ?? '';
62 const value = chip.dataset.value ?? '';
63 const params = new URLSearchParams(window.location.search);
64 const current = params.getAll(filterName);
65
66 if (current.includes(value)) {
67 params.delete(filterName);
68 current.filter((v) => v !== value).forEach((v) => params.append(filterName, v));
69 chip.classList.remove('active');
70 } else {
71 params.append(filterName, value);
72 chip.classList.add('active');
73 }
74
75 const url = '/explore?' + params.toString();
76 history.pushState({}, '', url);
77
78 const htmx = (window as unknown as Record<string, unknown>).htmx as
79 | { ajax: (m: string, u: string, o: Record<string, unknown>) => void }
80 | undefined;
81 htmx?.ajax('GET', url, { target: '#repo-grid', swap: 'innerHTML' });
82 });
83 });
84 }
85
86 // ── Semantic search mode ──────────────────────────────────────────────────────
87
88 function setupSemanticSearch(): void {
89 const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
90 const searchClear = document.getElementById('search-clear') as HTMLButtonElement | null;
91 const searchSpinner = document.getElementById('search-spinner') as HTMLElement | null;
92 const searchField = document.getElementById('search-field') as HTMLElement | null;
93 const searchResults = document.getElementById('search-results') as HTMLElement | null;
94 const browseLayout = document.getElementById('browse-layout') as HTMLElement | null;
95 const typeBar = document.getElementById('search-type-bar') as HTMLElement | null;
96
97 if (!searchInput || !searchResults) return;
98
99 let debounceTimer: ReturnType<typeof setTimeout> | null = null;
100 let currentQuery = '';
101 let currentType = 'repos';
102 let pendingAbort: AbortController | null = null;
103
104 // Type pill toggle
105 document.querySelectorAll<HTMLButtonElement>('[data-search-type]').forEach((pill) => {
106 pill.addEventListener('click', () => {
107 document.querySelectorAll('[data-search-type]').forEach((p) =>
108 p.classList.remove('search-type-pill--active'),
109 );
110 pill.classList.add('search-type-pill--active');
111 currentType = pill.dataset.searchType ?? 'repos';
112 if (currentQuery.length >= 2) scheduleSearch(currentQuery);
113 });
114 });
115
116 searchClear?.addEventListener('click', () => {
117 searchInput.value = '';
118 clearSearch();
119 });
120
121 searchInput.addEventListener('input', () => {
122 currentQuery = searchInput.value.trim();
123 if (debounceTimer) clearTimeout(debounceTimer);
124
125 if (!currentQuery) { clearSearch(); return; }
126 if (searchClear) searchClear.style.display = 'flex';
127 if (typeBar) typeBar.style.display = 'flex';
128 scheduleSearch(currentQuery);
129 });
130
131 searchInput.addEventListener('keydown', (e) => {
132 if (e.key === 'Escape') { searchInput.value = ''; clearSearch(); }
133 });
134
135 function scheduleSearch(q: string): void {
136 debounceTimer = setTimeout(() => performSearch(q, currentType), 300);
137 }
138
139 function clearSearch(): void {
140 if (pendingAbort) { pendingAbort.abort(); pendingAbort = null; }
141 currentQuery = '';
142 if (searchClear) searchClear.style.display = 'none';
143 if (typeBar) typeBar.style.display = 'none';
144 if (searchResults) searchResults.style.display = 'none';
145 if (browseLayout) browseLayout.style.display = '';
146 if (searchField) searchField.classList.remove('search-field--active');
147 }
148
149 async function performSearch(q: string, type: string): Promise<void> {
150 if (pendingAbort) pendingAbort.abort();
151 pendingAbort = new AbortController();
152
153 if (searchField) searchField.classList.add('search-field--active');
154 if (searchSpinner) searchSpinner.style.display = 'flex';
155 if (browseLayout) browseLayout.style.opacity = '0.3';
156
157 try {
158 const url = `/explore/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`;
159 const resp = await fetch(url, { signal: pendingAbort.signal });
160 if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
161 const html = await resp.text();
162
163 if (browseLayout) { browseLayout.style.display = 'none'; browseLayout.style.opacity = ''; }
164 searchResults.style.display = 'block';
165 searchResults.innerHTML = html;
166 } catch (err) {
167 if ((err as Error).name === 'AbortError') return;
168 if (browseLayout) { browseLayout.style.display = ''; browseLayout.style.opacity = ''; }
169 searchResults.innerHTML = `<div class="search-results__empty">
170 <div class="search-results__empty-icon">⚠</div>
171 <p class="search-results__empty-title">Search unavailable</p>
172 <p class="search-results__empty-sub">${String((err as Error).message ?? err)}</p>
173 </div>`;
174 searchResults.style.display = 'block';
175 } finally {
176 if (searchSpinner) searchSpinner.style.display = 'none';
177 if (browseLayout && browseLayout.style.opacity) browseLayout.style.opacity = '';
178 pendingAbort = null;
179 }
180 }
181 }
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago