Skip to main content

max / goingson

13.4 KB · 458 lines History Blame Raw
1 /**
2 * GoingsOn - Command Palette / Global Search (Cmd+K)
3 * Full-text search across tasks, projects, emails, events, contacts.
4 */
5
6 (function() {
7 'use strict';
8
9 const esc = GoingsOn.utils.escapeHtml;
10 const escAttr = GoingsOn.utils.escapeAttr;
11
12 let overlay = null;
13 let debounceTimer = null;
14 let selectedIndex = -1;
15 let currentResults = [];
16
17 // ============ Overlay Creation ============
18
19 function createOverlay() {
20 if (overlay) return overlay;
21
22 overlay = document.createElement('div');
23 overlay.id = 'command-palette';
24 overlay.className = 'hidden';
25 overlay.innerHTML = `
26 <div class="cp-backdrop"></div>
27 <div class="cp-dialog">
28 <div class="cp-input-row">
29 <span class="cp-icon">&#128269;</span>
30 <input type="text" id="cp-input" class="cp-input"
31 placeholder="Search tasks, projects, emails, events, contacts..."
32 autocomplete="off" spellcheck="false">
33 <kbd class="cp-esc">Esc</kbd>
34 </div>
35 <div id="cp-filters" class="cp-filters"></div>
36 <div id="cp-results" class="cp-results">
37 <div class="cp-hint">
38 Type to search across everything. Use <kbd>is:overdue</kbd> <kbd>tag:work</kbd> <kbd>type:task</kbd> <kbd>in:ProjectName</kbd> to filter.
39 </div>
40 </div>
41 </div>
42 `;
43
44 overlay.querySelector('.cp-backdrop').addEventListener('click', close);
45
46 const input = overlay.querySelector('#cp-input');
47 input.addEventListener('input', onInput);
48 input.addEventListener('keydown', onKeydown);
49
50 document.body.appendChild(overlay);
51 return overlay;
52 }
53
54 // ============ Open / Close ============
55
56 function open() {
57 createOverlay();
58 overlay.classList.remove('hidden');
59 selectedIndex = -1;
60 currentResults = [];
61
62 const input = overlay.querySelector('#cp-input');
63 input.value = '';
64 input.focus();
65
66 const results = overlay.querySelector('#cp-results');
67 results.innerHTML = `
68 <div class="cp-hint">
69 Type to search across everything. Use <kbd>is:overdue</kbd> <kbd>tag:work</kbd> <kbd>type:task</kbd> <kbd>in:ProjectName</kbd> to filter.
70 </div>
71 `;
72 overlay.querySelector('#cp-filters').innerHTML = '';
73 }
74
75 function close() {
76 if (overlay) {
77 overlay.classList.add('hidden');
78 }
79 }
80
81 function isOpen() {
82 return overlay && !overlay.classList.contains('hidden');
83 }
84
85 // ============ Search ============
86
87 function onInput() {
88 clearTimeout(debounceTimer);
89 const query = overlay.querySelector('#cp-input').value.trim();
90
91 if (!query) {
92 overlay.querySelector('#cp-results').innerHTML = `
93 <div class="cp-hint">
94 Type to search across everything. Use <kbd>is:overdue</kbd> <kbd>tag:work</kbd> <kbd>type:task</kbd> <kbd>in:ProjectName</kbd> to filter.
95 </div>
96 `;
97 overlay.querySelector('#cp-filters').innerHTML = '';
98 currentResults = [];
99 selectedIndex = -1;
100 return;
101 }
102
103 debounceTimer = setTimeout(() => runSearch(query), 150);
104 }
105
106 async function runSearch(query) {
107 try {
108 const response = await GoingsOn.api.search.query({
109 query: query,
110 limit: 20,
111 offset: 0,
112 });
113
114 currentResults = response.results || [];
115 selectedIndex = currentResults.length > 0 ? 0 : -1;
116 renderResults(currentResults, response.total || 0);
117 renderFilters(response.activeFilters || []);
118 } catch (err) {
119 overlay.querySelector('#cp-results').innerHTML = `
120 <div class="cp-empty">Search failed. Try again.</div>
121 `;
122 }
123 }
124
125 // ============ Rendering ============
126
127 const typeIcons = {
128 task: '&#9745;',
129 project: '&#9881;',
130 email: '&#9993;',
131 event: '&#128197;',
132 contact: '&#128100;',
133 };
134
135 const typeLabels = {
136 task: 'Task',
137 project: 'Project',
138 email: 'Email',
139 event: 'Event',
140 contact: 'Contact',
141 };
142
143 function renderResults(results, total) {
144 const container = overlay.querySelector('#cp-results');
145
146 if (results.length === 0) {
147 container.innerHTML = '<div class="cp-empty">No results found.</div>';
148 return;
149 }
150
151 let html = '';
152 for (let i = 0; i < results.length; i++) {
153 const r = results[i];
154 const icon = typeIcons[r.resultType] || '';
155 const label = typeLabels[r.resultType] || r.resultType;
156 const selected = i === selectedIndex ? ' cp-selected' : '';
157 const project = r.projectName ? `<span class="cp-project">${esc(r.projectName)}</span>` : '';
158 const snippet = r.snippet ? `<span class="cp-snippet">${esc(r.snippet)}</span>` : '';
159
160 html += `<div class="cp-result${selected}" data-index="${i}" onmouseenter="GoingsOn.search._hover(${i})" onclick="GoingsOn.search._select(${i})">
161 <span class="cp-type-icon" title="${label}">${icon}</span>
162 <div class="cp-result-body">
163 <span class="cp-title">${esc(r.title)}</span>
164 ${project}${snippet}
165 </div>
166 <span class="cp-type-badge">${label}</span>
167 </div>`;
168 }
169
170 if (total > results.length) {
171 html += `<div class="cp-more">${total - results.length} more results</div>`;
172 }
173
174 container.innerHTML = html;
175 }
176
177 function renderFilters(filters) {
178 const container = overlay.querySelector('#cp-filters');
179 if (!filters || filters.length === 0) {
180 container.innerHTML = '';
181 return;
182 }
183 container.innerHTML = filters.map(f => `<span class="cp-filter-tag">${esc(f)}</span>`).join('');
184 }
185
186 function updateSelection() {
187 if (!overlay) return;
188 const items = overlay.querySelectorAll('.cp-result');
189 items.forEach((el, i) => {
190 el.classList.toggle('cp-selected', i === selectedIndex);
191 });
192 if (selectedIndex >= 0 && items[selectedIndex]) {
193 items[selectedIndex].scrollIntoView({ block: 'nearest' });
194 }
195 }
196
197 // ============ Keyboard Navigation ============
198
199 function onKeydown(e) {
200 if (e.key === 'Escape') {
201 e.preventDefault();
202 close();
203 return;
204 }
205
206 if (e.key === 'ArrowDown') {
207 e.preventDefault();
208 if (currentResults.length > 0) {
209 selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1);
210 updateSelection();
211 }
212 return;
213 }
214
215 if (e.key === 'ArrowUp') {
216 e.preventDefault();
217 if (currentResults.length > 0) {
218 selectedIndex = Math.max(selectedIndex - 1, 0);
219 updateSelection();
220 }
221 return;
222 }
223
224 if (e.key === 'Enter') {
225 e.preventDefault();
226 if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
227 navigateToResult(currentResults[selectedIndex]);
228 }
229 return;
230 }
231 }
232
233 // ============ Navigation ============
234
235 function navigateToResult(result) {
236 close();
237
238 switch (result.resultType) {
239 case 'task':
240 GoingsOn.navigation.switchView('tasks');
241 setTimeout(() => GoingsOn.tasks.openEdit(result.id), 100);
242 break;
243 case 'project':
244 GoingsOn.navigation.switchView('projects');
245 setTimeout(() => GoingsOn.projects.openEdit(result.id), 100);
246 break;
247 case 'email':
248 GoingsOn.navigation.switchView('emails');
249 setTimeout(() => GoingsOn.emails.open(result.id), 100);
250 break;
251 case 'event':
252 GoingsOn.navigation.switchView('events');
253 setTimeout(() => GoingsOn.events.openEdit(result.id), 100);
254 break;
255 case 'contact':
256 GoingsOn.navigation.switchView('contacts');
257 setTimeout(() => GoingsOn.contacts.openEdit(result.id), 100);
258 break;
259 }
260 }
261
262 // ============ Mouse Handlers ============
263
264 function hoverResult(index) {
265 selectedIndex = index;
266 updateSelection();
267 }
268
269 function selectResult(index) {
270 if (index >= 0 && index < currentResults.length) {
271 navigateToResult(currentResults[index]);
272 }
273 }
274
275 // ============ Styles ============
276
277 const style = document.createElement('style');
278 style.textContent = `
279 #command-palette {
280 position: fixed;
281 inset: 0;
282 z-index: 5000;
283 display: flex;
284 align-items: flex-start;
285 justify-content: center;
286 padding-top: 15vh;
287 }
288 #command-palette.hidden { display: none; }
289
290 .cp-backdrop {
291 position: absolute;
292 inset: 0;
293 background: rgba(0, 0, 0, 0.5);
294 }
295
296 .cp-dialog {
297 position: relative;
298 width: 90%;
299 max-width: 560px;
300 background: var(--bg-card);
301 border: var(--border-width) solid var(--border-color);
302 border-radius: var(--radius-md);
303 box-shadow: var(--shadow-brutal);
304 overflow: hidden;
305 }
306
307 .cp-input-row {
308 display: flex;
309 align-items: center;
310 gap: 0.5rem;
311 padding: 0.75rem 1rem;
312 border-bottom: 1px solid var(--border-color);
313 }
314
315 .cp-icon {
316 font-size: 1.1rem;
317 opacity: 0.5;
318 }
319
320 .cp-input {
321 flex: 1;
322 border: none;
323 outline: none;
324 background: transparent;
325 font-size: 1rem;
326 font-family: inherit;
327 color: var(--text-primary);
328 }
329
330 .cp-esc {
331 font-size: 0.7rem;
332 padding: 0.15rem 0.4rem;
333 background: var(--bg-secondary);
334 border: 1px solid var(--border-color);
335 border-radius: 3px;
336 color: var(--text-secondary);
337 }
338
339 .cp-filters {
340 display: flex;
341 gap: 0.4rem;
342 padding: 0 1rem;
343 flex-wrap: wrap;
344 }
345 .cp-filters:not(:empty) {
346 padding-top: 0.5rem;
347 padding-bottom: 0.25rem;
348 }
349
350 .cp-filter-tag {
351 font-size: 0.75rem;
352 padding: 0.15rem 0.5rem;
353 background: var(--bg-secondary);
354 border-radius: var(--radius-sm);
355 color: var(--text-secondary);
356 }
357
358 .cp-results {
359 max-height: 360px;
360 overflow-y: auto;
361 }
362
363 .cp-hint, .cp-empty {
364 padding: 1.5rem 1rem;
365 text-align: center;
366 font-size: 0.85rem;
367 color: var(--text-secondary);
368 line-height: 1.6;
369 }
370 .cp-hint kbd {
371 font-size: 0.75rem;
372 padding: 0.1rem 0.35rem;
373 background: var(--bg-secondary);
374 border: 1px solid var(--border-color);
375 border-radius: 3px;
376 }
377
378 .cp-result {
379 display: flex;
380 align-items: center;
381 gap: 0.6rem;
382 padding: 0.5rem 1rem;
383 cursor: pointer;
384 border-bottom: 1px solid var(--border-color);
385 }
386 .cp-result:last-child { border-bottom: none; }
387 .cp-result:hover, .cp-result.cp-selected {
388 background: var(--bg-secondary);
389 }
390
391 .cp-type-icon {
392 font-size: 1rem;
393 width: 1.5rem;
394 text-align: center;
395 flex-shrink: 0;
396 }
397
398 .cp-result-body {
399 flex: 1;
400 min-width: 0;
401 display: flex;
402 flex-direction: column;
403 gap: 0.15rem;
404 }
405
406 .cp-title {
407 font-size: 0.9rem;
408 color: var(--text-primary);
409 white-space: nowrap;
410 overflow: hidden;
411 text-overflow: ellipsis;
412 }
413
414 .cp-project {
415 font-size: 0.75rem;
416 color: var(--text-secondary);
417 }
418
419 .cp-snippet {
420 font-size: 0.75rem;
421 color: var(--text-secondary);
422 white-space: nowrap;
423 overflow: hidden;
424 text-overflow: ellipsis;
425 }
426
427 .cp-type-badge {
428 font-size: 0.7rem;
429 padding: 0.15rem 0.4rem;
430 background: var(--bg-secondary);
431 border: 1px solid var(--border-color);
432 border-radius: var(--radius-sm);
433 color: var(--text-secondary);
434 flex-shrink: 0;
435 }
436
437 .cp-more {
438 padding: 0.5rem 1rem;
439 text-align: center;
440 font-size: 0.8rem;
441 color: var(--text-secondary);
442 opacity: 0.7;
443 }
444 `;
445 document.head.appendChild(style);
446
447 // ============ Export ============
448
449 GoingsOn.search = {
450 open,
451 close,
452 isOpen,
453 _hover: hoverResult,
454 _select: selectResult,
455 };
456
457 })();
458