Skip to main content

max / goingson

9.0 KB · 286 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.escapeAttrValue;
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}" data-mouseenter="search._hover" data-act="search._select" data-args='[${i}]'>
161 <span class="cp-type-icon" title="${escAttr(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">${esc(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 // ============ Export ============
276
277 GoingsOn.search = {
278 open,
279 close,
280 isOpen,
281 _hover: hoverResult,
282 _select: selectResult,
283 };
284
285 })();
286