Skip to main content

max / balanced_breakfast

21.2 KB · 521 lines History Blame Raw
1 /**
2 * @fileoverview App bootstrap, keyboard shortcuts, and native menu integration.
3 *
4 * `init()` runs on DOMContentLoaded: loads theme, data, wires up event listeners,
5 * and shows first-run welcome. Keyboard shortcuts use vim-style navigation (j/k)
6 * plus single-key actions (s=star, r=read, /=search, ?=help).
7 */
8 (function() {
9 'use strict';
10
11 const { invoke } = window.__TAURI__.core;
12
13 /** Guard to prevent registering event listeners multiple times. */
14 let initialized = false;
15
16 /** Monotonic request ID so concurrent search/sort loads resolve in order. */
17 let loadRequestId = 0;
18
19 /**
20 * Initialize the application: load theme and data, wire up UI, show welcome.
21 * @returns {Promise<void>}
22 */
23 async function init() {
24 if (initialized) return;
25 initialized = true;
26
27 // Load theme before rendering content
28 await BB.themes.init();
29
30 // Load data — catch so a backend error doesn't leave a blank screen
31 try {
32 await BB.sources.load();
33 await BB.items.load();
34 } catch (err) {
35 BB.ui.showErrorWithRetry(
36 'Failed to load data: ' + BB.utils.getErrorMessage(err),
37 () => { BB.sources.load(); BB.items.load(); }
38 );
39 }
40
41 // Migrate localStorage saved items to database bookmarks, then update badge
42 BB.bookmarks.migrateFromLocalStorage().then(() => BB.bookmarks.updateBadge());
43
44 BB.navigation.init();
45
46 // First-run welcome
47 try {
48 const welcomed = await invoke('get_config', { key: 'bb-welcomed' });
49 if (!welcomed) {
50 showWelcome();
51 }
52 } catch (_) {
53 // Non-critical — skip welcome on error
54 }
55
56 // Search input — debounced at 300ms, with request ID so last request wins
57 const searchInput = document.getElementById('search-input');
58 const searchSpinner = document.getElementById('search-spinner');
59 searchInput.addEventListener('input', BB.utils.debounce(async () => {
60 BB.state.set('currentSearch', searchInput.value);
61 BB.state.resetPagination();
62 const myId = ++loadRequestId;
63 searchSpinner.classList.add('active');
64 await BB.items.load();
65 if (loadRequestId === myId) {
66 searchSpinner.classList.remove('active');
67 }
68 }, 300));
69
70 // Sort select — guarded so rapid changes don't interleave results
71 document.getElementById('sort-select').addEventListener('change', async (e) => {
72 BB.state.set('currentOrder', e.target.value);
73 BB.state.resetPagination();
74 ++loadRequestId;
75 await BB.items.load();
76 });
77
78 // Button handlers
79 document.getElementById('refresh-btn').addEventListener('click', BB.feeds.refresh);
80 document.getElementById('add-feed-btn').addEventListener('click', BB.feeds.openAddFeed);
81 document.getElementById('save-detail').addEventListener('click', BB.detail.bookmarkItem);
82 document.getElementById('close-detail').addEventListener('click', BB.detail.close);
83 document.getElementById('saved-articles-btn').addEventListener('click', () => BB.sources.select('__saved__'));
84 document.getElementById('load-more-btn').addEventListener('click', BB.items.loadMore);
85 document.getElementById('settings-btn').addEventListener('click', showSettings);
86 document.getElementById('sync-settings-btn').addEventListener('click', BB.sync.openSettings);
87 document.getElementById('help-btn').addEventListener('click', showHelp);
88 document.getElementById('unread-toggle').addEventListener('click', toggleUnreadFilter);
89 document.getElementById('mark-all-read-btn').addEventListener('click', markAllReadGlobal);
90
91 // Modal close on overlay click
92 document.getElementById('modal-overlay').addEventListener('click', (e) => {
93 if (e.target.id === 'modal-overlay') BB.ui.closeModal();
94 });
95 document.getElementById('close-modal').addEventListener('click', BB.ui.closeModal);
96
97 // Replace default context menu (which has "Reload" → full page reload)
98 // with a minimal custom one using "Refresh" terminology.
99 document.addEventListener('contextmenu', (e) => {
100 e.preventDefault();
101 showContextMenu(e.clientX, e.clientY);
102 });
103
104 // Keyboard shortcuts
105 document.addEventListener('keydown', handleKeyboard);
106
107 // Menu events from native menu bar
108 setupMenuListeners();
109 }
110
111 /**
112 * Toggle the "unread only" filter on/off.
113 */
114 function toggleUnreadFilter() {
115 const btn = document.getElementById('unread-toggle');
116 const active = btn.getAttribute('aria-pressed') === 'true';
117 btn.setAttribute('aria-pressed', !active);
118 btn.classList.toggle('active', !active);
119 BB.state.set('unreadOnly', !active);
120 BB.state.resetPagination(true);
121 BB.items.load();
122 }
123
124 /**
125 * Mark all items as read (global or per-source).
126 */
127 async function markAllReadGlobal() {
128 const source = BB.state.currentSource || null;
129 const label = source
130 ? (BB.state.sources.find(s => s.id === source) || {}).name || 'this source'
131 : 'all sources';
132 const ok = await BB.ui.confirmAction('Mark all items in ' + label + ' as read?');
133 if (!ok) return;
134 try {
135 await BB.api.items.markAllRead(source);
136 BB.ui.showToast('Marked all as read');
137 await BB.sources.load();
138 await BB.items.load();
139 } catch (err) {
140 BB.ui.showToast('Failed: ' + BB.utils.getErrorMessage(err), 'error');
141 }
142 }
143
144 /**
145 * Global keyboard shortcut handler. Skipped when focus is in a form input.
146 * Key choices follow common reader conventions: j/k from vim, s/r from
147 * Google Reader, /=search from vim, ?=help from many CLI tools.
148 * @param {KeyboardEvent} e - The keydown event.
149 */
150 function handleKeyboard(e) {
151 // F3 justified touch branch: keyboard shortcuts are skipped on
152 // touch devices (no physical keyboard, and the on-screen keyboard
153 // would inadvertently trigger j/k/s/r on every letter typed).
154 if (BB.state.isTouchDevice) return;
155
156 // Don't handle when typing in inputs
157 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
158 if (e.key === 'Escape') {
159 e.target.blur();
160 }
161 return;
162 }
163
164 const items = BB.state.items;
165 const selectedId = BB.state.selectedItemId;
166 const currentIdx = selectedId ? items.findIndex(i => i.id === selectedId) : -1;
167
168 switch (e.key) {
169 case '?': // Show help
170 e.preventDefault();
171 showHelp();
172 return;
173
174 case 'j': // Next item
175 e.preventDefault();
176 if (currentIdx < items.length - 1) {
177 BB.items.selectItem(items[currentIdx + 1].id);
178 } else if (items.length > 0 && currentIdx === -1) {
179 BB.items.selectItem(items[0].id);
180 }
181 break;
182
183 case 'k': // Previous item
184 e.preventDefault();
185 if (currentIdx > 0) {
186 BB.items.selectItem(items[currentIdx - 1].id);
187 }
188 break;
189
190 case 's': // Star/unstar
191 e.preventDefault();
192 if (selectedId) {
193 const item = items.find(i => i.id === selectedId);
194 if (item) BB.items.toggleStar(selectedId, item.isStarred);
195 }
196 break;
197
198 case 'r': // Toggle read
199 if (!e.metaKey && !e.ctrlKey) {
200 e.preventDefault();
201 if (selectedId) {
202 const item = items.find(i => i.id === selectedId);
203 if (item) BB.items.toggleRead(selectedId, item.isRead);
204 }
205 }
206 break;
207
208 case 'u': // Toggle unread only
209 e.preventDefault();
210 toggleUnreadFilter();
211 break;
212
213 case 'A': // Mark all as read (Shift+A)
214 if (e.shiftKey) {
215 e.preventDefault();
216 markAllReadGlobal();
217 }
218 break;
219
220 case '/': // Focus search
221 e.preventDefault();
222 document.getElementById('search-input').focus();
223 break;
224
225 case 'Escape':
226 if (document.querySelector('.main.reader-expanded')) {
227 BB.detail.collapseReader();
228 } else if (BB.state.selectedItemId) {
229 BB.detail.close();
230 }
231 break;
232
233 case 'o': // Open URL
234 case 'Enter':
235 if (selectedId) {
236 BB.detail.openUrl();
237 }
238 break;
239 }
240 }
241
242 /**
243 * Show a custom right-click context menu at the given coordinates.
244 * @param {number} x - Viewport X position in pixels.
245 * @param {number} y - Viewport Y position in pixels.
246 */
247 function showContextMenu(x, y) {
248 const old = document.getElementById('context-menu');
249 if (old) old.remove();
250
251 const menu = document.createElement('div');
252 menu.id = 'context-menu';
253 menu.className = 'context-menu';
254 menu.style.left = x + 'px';
255 menu.style.top = y + 'px';
256
257 const refreshItem = document.createElement('button');
258 refreshItem.className = 'context-menu-item';
259 refreshItem.textContent = 'Refresh Feeds';
260 refreshItem.onclick = () => { menu.remove(); BB.feeds.refresh(); };
261 menu.appendChild(refreshItem);
262
263 document.body.appendChild(menu);
264
265 // Clamp to viewport
266 const rect = menu.getBoundingClientRect();
267 if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
268 if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
269
270 // Dismiss on any click outside
271 requestAnimationFrame(() => {
272 document.addEventListener('click', function dismiss() {
273 menu.remove();
274 document.removeEventListener('click', dismiss);
275 }, { once: true });
276 });
277 }
278
279 /** Listen for Tauri native menu events and auto-fetch background updates. */
280 function setupMenuListeners() {
281 const listen = window.__TAURI__.event.listen;
282
283 // Auto-fetch background updates
284 listen('auto-fetch-complete', () => {
285 BB.sources.load();
286 BB.items.load();
287 });
288
289 listen('auto-fetch-error', (event) => {
290 const pluginId = event.payload?.pluginId || 'unknown';
291 const error = event.payload?.error || '';
292 BB.ui.showToast('Failed to fetch ' + pluginId + (error ? ': ' + error : ''), 'error');
293 });
294
295 listen('feed-circuit-broken', (event) => {
296 const pluginId = event.payload?.pluginId || 'unknown';
297 BB.ui.showErrorWithRetry(
298 'Feed "' + pluginId + '" disabled after repeated failures',
299 async () => {
300 try {
301 await BB.api.feeds.resetCircuitBreaker(pluginId);
302 BB.ui.showToast('Feed re-enabled');
303 BB.sources.load();
304 BB.items.load();
305 } catch (err) {
306 BB.ui.showToast('Failed to reset: ' + BB.utils.getErrorMessage(err), 'error');
307 }
308 }
309 );
310 BB.sources.load();
311 });
312
313 listen('menu:refresh', () => BB.feeds.refresh());
314 listen('menu:add_feed', () => BB.feeds.openAddFeed());
315 listen('menu:import_opml', () => BB.feeds.importOpml());
316 listen('menu:export_opml', () => BB.feeds.exportOpml());
317 listen('menu:view_all', async () => {
318 BB.state.set('currentSource', '');
319 BB.state.resetPagination();
320 await BB.items.load();
321 BB.sources.render(BB.state.sources);
322 });
323 listen('menu:view_unread', () => {
324 BB.state.set('currentOrder', 'unread');
325 document.getElementById('sort-select').value = 'unread';
326 BB.state.resetPagination();
327 BB.items.load();
328 });
329 listen('menu:view_starred', () => {
330 BB.state.set('currentOrder', 'starred');
331 document.getElementById('sort-select').value = 'starred';
332 BB.state.resetPagination();
333 BB.items.load();
334 });
335
336 // Session summary on close
337 window.addEventListener('beforeunload', () => {
338 const read = BB.state.sessionArticlesRead;
339 const starred = BB.state.sessionArticlesStarred;
340 if (read > 0 || starred > 0) {
341 const parts = [];
342 if (read > 0) parts.push(read + ' read');
343 if (starred > 0) parts.push(starred + ' starred');
344 BB.ui.showToast('This session: ' + parts.join(', '));
345 }
346 });
347 }
348
349 /** Show the settings modal with theme selector and data management. */
350 function showSettings() {
351 const body = document.getElementById('modal-body');
352 const title = document.getElementById('modal-title');
353 title.textContent = 'Settings';
354 body.innerHTML = '';
355 const content = document.createElement('div');
356 content.className = 'settings-content';
357
358 // Theme section
359 const themeGroup = document.createElement('div');
360 themeGroup.className = 'form-group';
361 const themeLabel = document.createElement('label');
362 themeLabel.textContent = 'Theme';
363 const themeContainer = document.createElement('div');
364 themeContainer.id = 'settings-theme-container';
365 themeGroup.appendChild(themeLabel);
366 themeGroup.appendChild(themeContainer);
367
368 // F4 (2026-06-02): inline layout retired in favor of .theme-actions class.
369 const themeActions = document.createElement('div');
370 themeActions.className = 'theme-actions';
371
372 const themeImportBtn = document.createElement('button');
373 themeImportBtn.className = 'btn btn-small';
374 themeImportBtn.textContent = 'Import Theme';
375 themeImportBtn.onclick = () => BB.themes.importTheme();
376 themeActions.appendChild(themeImportBtn);
377
378 const themeExportBtn = document.createElement('button');
379 themeExportBtn.className = 'btn btn-small';
380 themeExportBtn.textContent = 'Export Current';
381 themeExportBtn.onclick = () => BB.themes.exportTheme();
382 themeActions.appendChild(themeExportBtn);
383
384 themeGroup.appendChild(themeActions);
385 content.appendChild(themeGroup);
386
387 // Data management section
388 const dataGroup = document.createElement('div');
389 dataGroup.className = 'form-group';
390 const dataLabel = document.createElement('label');
391 dataLabel.textContent = 'Data';
392 dataGroup.appendChild(dataLabel);
393 // F4 (2026-06-02): inline layout retired in favor of .form-row primitive.
394 const dataActions = document.createElement('div');
395 dataActions.className = 'form-row';
396
397 const importBtn = document.createElement('button');
398 importBtn.className = 'btn btn-small';
399 importBtn.textContent = 'Import OPML';
400 importBtn.onclick = () => { BB.ui.closeModal(); BB.feeds.importOpml(); };
401 dataActions.appendChild(importBtn);
402
403 const exportBtn = document.createElement('button');
404 exportBtn.className = 'btn btn-small';
405 exportBtn.textContent = 'Export OPML';
406 exportBtn.onclick = () => { BB.ui.closeModal(); BB.feeds.exportOpml(); };
407 dataActions.appendChild(exportBtn);
408
409 dataGroup.appendChild(dataActions);
410 content.appendChild(dataGroup);
411
412 // Sync section (if available)
413 if (BB.sync) {
414 const syncGroup = document.createElement('div');
415 syncGroup.className = 'form-group';
416 const syncLabel = document.createElement('label');
417 syncLabel.textContent = 'Cloud Sync';
418 syncGroup.appendChild(syncLabel);
419 const syncBtn = document.createElement('button');
420 syncBtn.className = 'btn btn-small';
421 syncBtn.textContent = 'Sync Settings';
422 syncBtn.onclick = () => { BB.ui.closeModal(); BB.sync.openSettings(); };
423 syncGroup.appendChild(syncBtn);
424 content.appendChild(syncGroup);
425 }
426
427 const actions = document.createElement('div');
428 actions.className = 'form-actions';
429 const closeBtn = document.createElement('button');
430 closeBtn.className = 'btn';
431 closeBtn.textContent = 'Close';
432 closeBtn.addEventListener('click', BB.ui.closeModal);
433 actions.appendChild(closeBtn);
434 content.appendChild(actions);
435
436 body.appendChild(content);
437 BB.themes.buildSelector(themeContainer);
438 BB.ui.openModal();
439 }
440
441 /** Show the keyboard shortcuts help modal. */
442 function showHelp() {
443 const body = document.getElementById('modal-body');
444 const title = document.getElementById('modal-title');
445 title.textContent = 'Keyboard Shortcuts';
446 body.innerHTML = `
447 <div class="help-shortcuts">
448 <div class="help-section">
449 <h3>Navigation</h3>
450 <div class="help-row"><kbd>j</kbd><span>Next item</span></div>
451 <div class="help-row"><kbd>k</kbd><span>Previous item</span></div>
452 <div class="help-row"><kbd>o</kbd> / <kbd>Enter</kbd><span>Open in browser</span></div>
453 <div class="help-row"><kbd>/</kbd><span>Focus search</span></div>
454 <div class="help-row"><kbd>Esc</kbd><span>Close detail panel</span></div>
455 </div>
456 <div class="help-section">
457 <h3>Actions</h3>
458 <div class="help-row"><kbd>s</kbd><span>Star / unstar</span></div>
459 <div class="help-row"><kbd>r</kbd><span>Toggle read / unread</span></div>
460 <div class="help-row"><kbd>u</kbd><span>Toggle unread-only filter</span></div>
461 <div class="help-row"><kbd>Shift</kbd> <kbd>A</kbd><span>Mark all as read</span></div>
462 <div class="help-row"><kbd>?</kbd><span>Show this help</span></div>
463 </div>
464 <div class="help-section">
465 <h3>Menu Shortcuts</h3>
466 <div class="help-row"><kbd>\u2318R</kbd><span>Refresh all feeds</span></div>
467 <div class="help-row"><kbd>\u2318N</kbd><span>Add new feed</span></div>
468 <div class="help-row"><kbd>\u2318I</kbd><span>Import OPML</span></div>
469 <div class="help-row"><kbd>\u2318E</kbd><span>Export OPML</span></div>
470 </div>
471 </div>
472 `;
473 BB.ui.openModal();
474 }
475
476 /** Show the first-run welcome modal. Sets user_config flag to prevent re-showing. */
477 function showWelcome() {
478 const body = document.getElementById('modal-body');
479 const title = document.getElementById('modal-title');
480 title.textContent = 'Welcome to Balanced Breakfast';
481 body.innerHTML = '';
482 const content = document.createElement('div');
483 content.className = 'welcome-content';
484 content.innerHTML =
485 '<p>Your personal feed reader for RSS, Atom, podcasts, and more.</p>' +
486 '<h3>Getting Started</h3>' +
487 '<p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File &gt; Import OPML</strong>.</p>' +
488 '<h3>Keyboard Shortcuts</h3>' +
489 '<p>Press <kbd>?</kbd> anytime to see all shortcuts. Navigate with <kbd>j</kbd>/<kbd>k</kbd>, star with <kbd>s</kbd>, toggle read with <kbd>r</kbd>.</p>' +
490 '<h3>Themes &amp; Settings</h3>' +
491 '<p>Click the gear icon (<strong>&#9881;</strong>) in the header to change themes and configure preferences.</p>';
492
493 const cta = document.createElement('div');
494 cta.className = 'welcome-cta';
495 const addBtn = document.createElement('button');
496 addBtn.className = 'btn btn-success';
497 addBtn.textContent = 'Add Your First Feed';
498 addBtn.addEventListener('click', BB.feeds.openAddFeed);
499 const exploreBtn = document.createElement('button');
500 exploreBtn.className = 'btn';
501 exploreBtn.textContent = 'Explore First';
502 exploreBtn.addEventListener('click', BB.ui.closeModal);
503 cta.appendChild(addBtn);
504 cta.appendChild(exploreBtn);
505 content.appendChild(cta);
506
507 body.appendChild(content);
508 BB.ui.openModal();
509 invoke('set_config', { key: 'bb-welcomed', value: '1' });
510 }
511
512 BB.app = { init, showHelp, showSettings, showWelcome };
513
514 // Auto-init when DOM ready
515 if (document.readyState === 'loading') {
516 document.addEventListener('DOMContentLoaded', init);
517 } else {
518 init();
519 }
520 })();
521