max / balanced_breakfast
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
19 files changed,
+385 insertions,
-162 deletions
| @@ -12,8 +12,10 @@ | |||
| 12 | 12 | <header class="header"> | |
| 13 | 13 | <h1>Balanced Breakfast</h1> | |
| 14 | 14 | <div class="header-actions"> | |
| 15 | + | <label for="search-input" class="sr-only">Search items</label> | |
| 15 | 16 | <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input"> | |
| 16 | 17 | <span id="search-spinner" class="search-spinner" aria-hidden="true"></span> | |
| 18 | + | <label for="sort-select" class="sr-only">Sort order</label> | |
| 17 | 19 | <select id="sort-select" class="sort-select" title="Sort order"> | |
| 18 | 20 | <option value="chronological">Newest First</option> | |
| 19 | 21 | <option value="score">By Score</option> | |
| @@ -22,7 +24,7 @@ | |||
| 22 | 24 | </select> | |
| 23 | 25 | <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button> | |
| 24 | 26 | <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button> | |
| 25 | - | <button class="btn btn-small" title="Settings" onclick="BB.app.showSettings()">⚙</button> | |
| 27 | + | <button id="settings-btn" class="btn btn-small" title="Settings">⚙</button> | |
| 26 | 28 | </div> | |
| 27 | 29 | </header> | |
| 28 | 30 | ||
| @@ -31,14 +33,16 @@ | |||
| 31 | 33 | <!-- Sources sidebar --> | |
| 32 | 34 | <aside class="sidebar"> | |
| 33 | 35 | <h2>Sources</h2> | |
| 34 | - | <ul id="sources-list" class="sources-list"> | |
| 35 | - | <li class="source-item active" data-source="" onclick="BB.sources.select('')"> | |
| 36 | - | <span class="source-name">All</span> | |
| 37 | - | <span class="source-count">0</span> | |
| 38 | - | </li> | |
| 39 | - | </ul> | |
| 36 | + | <nav aria-label="Feed sources"> | |
| 37 | + | <ul id="sources-list" class="sources-list"> | |
| 38 | + | <li class="source-item active" data-source=""> | |
| 39 | + | <span class="source-name">All</span> | |
| 40 | + | <span class="source-count">0</span> | |
| 41 | + | </li> | |
| 42 | + | </ul> | |
| 43 | + | </nav> | |
| 40 | 44 | <div class="sidebar-footer"> | |
| 41 | - | <button id="sync-settings-btn" class="btn btn-small" title="Cloud Sync Settings" onclick="BB.sync.openSettings()">Sync</button> | |
| 45 | + | <button id="sync-settings-btn" class="btn btn-small" title="Cloud Sync Settings">Sync</button> | |
| 42 | 46 | </div> | |
| 43 | 47 | </aside> | |
| 44 | 48 | ||
| @@ -64,7 +68,7 @@ | |||
| 64 | 68 | ||
| 65 | 69 | <!-- Modal overlay --> | |
| 66 | 70 | <div id="modal-overlay" class="modal-overlay"> | |
| 67 | - | <div class="modal-content"> | |
| 71 | + | <div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title"> | |
| 68 | 72 | <div class="modal-header"> | |
| 69 | 73 | <h2 id="modal-title">Modal</h2> | |
| 70 | 74 | <button id="close-modal" class="btn btn-small" aria-label="Close">×</button> |
| @@ -806,6 +806,7 @@ | |||
| 806 | 806 | "consecutive_failures": 0, | |
| 807 | 807 | "last_error": null, | |
| 808 | 808 | "last_success_at": null, | |
| 809 | + | "circuit_broken": 0, | |
| 809 | 810 | }); | |
| 810 | 811 | ||
| 811 | 812 | apply_upsert(&pool, "feeds", &feed_id, &data).await.unwrap(); |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | .search-input:focus { | |
| 92 | 92 | outline: none; | |
| 93 | 93 | border-color: var(--yolk); | |
| 94 | - | box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.15); | |
| 94 | + | box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 15%, transparent); | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | .sort-select { | |
| @@ -237,9 +237,9 @@ | |||
| 237 | 237 | .item:hover { background-color: var(--bg-secondary); } | |
| 238 | 238 | .item.unread { | |
| 239 | 239 | border-left: 3px solid var(--accent); | |
| 240 | - | background-color: rgba(201, 75, 75, 0.03); | |
| 240 | + | background-color: color-mix(in srgb, var(--accent) 6%, var(--bg-primary)); | |
| 241 | 241 | } | |
| 242 | - | .item.read { opacity: 0.75; } | |
| 242 | + | .item.read { color: var(--text-secondary); } | |
| 243 | 243 | .item.selected { background-color: var(--bg-tertiary); } | |
| 244 | 244 | .item.empty-state { | |
| 245 | 245 | text-align: center; | |
| @@ -387,7 +387,7 @@ | |||
| 387 | 387 | .modal-overlay { | |
| 388 | 388 | position: fixed; | |
| 389 | 389 | top: 0; left: 0; right: 0; bottom: 0; | |
| 390 | - | background-color: rgba(61, 50, 37, 0.4); | |
| 390 | + | background-color: color-mix(in srgb, var(--text-primary) 40%, transparent); | |
| 391 | 391 | display: none; | |
| 392 | 392 | align-items: center; | |
| 393 | 393 | justify-content: center; | |
| @@ -401,7 +401,7 @@ | |||
| 401 | 401 | max-width: 500px; | |
| 402 | 402 | max-height: 80vh; | |
| 403 | 403 | overflow-y: auto; | |
| 404 | - | box-shadow: 0 10px 40px rgba(61, 50, 37, 0.2); | |
| 404 | + | box-shadow: 0 10px 40px color-mix(in srgb, var(--text-primary) 20%, transparent); | |
| 405 | 405 | } | |
| 406 | 406 | ||
| 407 | 407 | .modal-header { | |
| @@ -460,7 +460,7 @@ | |||
| 460 | 460 | .form-input:focus { | |
| 461 | 461 | outline: none; | |
| 462 | 462 | border-color: var(--yolk); | |
| 463 | - | box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.15); | |
| 463 | + | box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 15%, transparent); | |
| 464 | 464 | } | |
| 465 | 465 | ||
| 466 | 466 | .form-actions { | |
| @@ -495,8 +495,8 @@ | |||
| 495 | 495 | font-weight: 500; | |
| 496 | 496 | font-size: 0.875rem; | |
| 497 | 497 | } | |
| 498 | - | .toast.success { border-left: 4px solid var(--success); background-color: rgba(107, 155, 90, 0.08); } | |
| 499 | - | .toast.error { border-left: 4px solid var(--accent); background-color: rgba(201, 75, 75, 0.08); } | |
| 498 | + | .toast.success { border-left: 4px solid var(--success); background-color: color-mix(in srgb, var(--success) 8%, var(--bg-primary)); } | |
| 499 | + | .toast.error { border-left: 4px solid var(--accent); background-color: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); } | |
| 500 | 500 | ||
| 501 | 501 | @keyframes slideIn { | |
| 502 | 502 | from { transform: translateX(100%); opacity: 0; } | |
| @@ -637,14 +637,14 @@ | |||
| 637 | 637 | .toast-action:focus-visible { | |
| 638 | 638 | outline: 2px solid var(--yolk); | |
| 639 | 639 | outline-offset: 2px; | |
| 640 | - | box-shadow: 0 0 0 4px rgba(232, 168, 65, 0.2); | |
| 640 | + | box-shadow: 0 0 0 4px color-mix(in srgb, var(--yolk) 20%, transparent); | |
| 641 | 641 | } | |
| 642 | 642 | ||
| 643 | 643 | .search-input:focus-visible, | |
| 644 | 644 | .form-input:focus-visible { | |
| 645 | 645 | outline: none; | |
| 646 | 646 | border-color: var(--yolk); | |
| 647 | - | box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.25); | |
| 647 | + | box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 25%, transparent); | |
| 648 | 648 | } | |
| 649 | 649 | ||
| 650 | 650 | /* Tag filter bar above sources list */ | |
| @@ -835,6 +835,68 @@ | |||
| 835 | 835 | font-weight: 500; | |
| 836 | 836 | } | |
| 837 | 837 | ||
| 838 | + | /* Sidebar footer */ | |
| 839 | + | .sidebar-footer { | |
| 840 | + | margin-top: auto; | |
| 841 | + | padding: 0.5rem 0.75rem; | |
| 842 | + | border-top: 1px solid var(--border); | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | /* Screen-reader only */ | |
| 846 | + | .sr-only { | |
| 847 | + | position: absolute; | |
| 848 | + | width: 1px; | |
| 849 | + | height: 1px; | |
| 850 | + | padding: 0; | |
| 851 | + | margin: -1px; | |
| 852 | + | overflow: hidden; | |
| 853 | + | clip: rect(0, 0, 0, 0); | |
| 854 | + | white-space: nowrap; | |
| 855 | + | border: 0; | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | /* Responsive breakpoints */ | |
| 859 | + | @media (max-width: 768px) { | |
| 860 | + | .sidebar { width: 180px; min-width: 180px; } | |
| 861 | + | .detail-panel { width: 300px; min-width: 300px; } | |
| 862 | + | .search-input { width: 140px; } | |
| 863 | + | .help-shortcuts { grid-template-columns: 1fr; } | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | @media (max-width: 600px) { | |
| 867 | + | .header { flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem 0.75rem; } | |
| 868 | + | .header h1 { font-size: 1.1rem; } | |
| 869 | + | .header-actions { flex-wrap: wrap; gap: 0.4rem; } | |
| 870 | + | .search-input { width: 100%; order: 10; } | |
| 871 | + | .sidebar { width: 160px; min-width: 160px; } | |
| 872 | + | .detail-panel { width: 260px; min-width: 260px; } | |
| 873 | + | .modal-content { width: 95%; } | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | /* Reduced motion */ | |
| 877 | + | @media (prefers-reduced-motion: reduce) { | |
| 878 | + | *, *::before, *::after { | |
| 879 | + | animation-duration: 0.01ms !important; | |
| 880 | + | animation-iteration-count: 1 !important; | |
| 881 | + | transition-duration: 0.01ms !important; | |
| 882 | + | } | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | /* Update banner */ | |
| 886 | + | .update-banner { | |
| 887 | + | position: fixed; | |
| 888 | + | bottom: 1rem; | |
| 889 | + | right: 1rem; | |
| 890 | + | background: var(--bg-secondary); | |
| 891 | + | border: 1px solid var(--border); | |
| 892 | + | border-radius: 8px; | |
| 893 | + | padding: 0.75rem 1rem; | |
| 894 | + | z-index: 9999; | |
| 895 | + | max-width: 320px; | |
| 896 | + | box-shadow: 0 4px 12px var(--shadow); | |
| 897 | + | font-size: 0.875rem; | |
| 898 | + | } | |
| 899 | + | ||
| 838 | 900 | /* Scrollbar */ | |
| 839 | 901 | ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| 840 | 902 | ::-webkit-scrollbar-track { background: var(--bg-secondary); } |
| @@ -63,6 +63,8 @@ | |||
| 63 | 63 | setTags: (busserId, tags) => invoke('set_feed_tags', { busserId, tags }), | |
| 64 | 64 | /** List all distinct tags across all feeds. */ | |
| 65 | 65 | listAllTags: () => invoke('list_all_tags'), | |
| 66 | + | /** Reset circuit breaker and retry fetch. */ | |
| 67 | + | resetCircuitBreaker: (busserId) => invoke('reset_circuit_breaker', { busserId }), | |
| 66 | 68 | }, | |
| 67 | 69 | ||
| 68 | 70 | // --- OPML: import/export feed subscriptions --- |
| @@ -10,40 +10,63 @@ | |||
| 10 | 10 | ||
| 11 | 11 | const { invoke } = window.__TAURI__.core; | |
| 12 | 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 | + | ||
| 13 | 19 | /** | |
| 14 | 20 | * Initialize the application: load theme and data, wire up UI, show welcome. | |
| 15 | 21 | */ | |
| 16 | 22 | async function init() { | |
| 23 | + | if (initialized) return; | |
| 24 | + | initialized = true; | |
| 25 | + | ||
| 17 | 26 | // Load theme before rendering content | |
| 18 | 27 | await BB.themes.init(); | |
| 19 | 28 | ||
| 20 | - | // Load data | |
| 21 | - | await BB.sources.load(); | |
| 22 | - | await BB.items.load(); | |
| 23 | - | ||
| 24 | - | // First-run welcome | |
| 25 | - | const welcomed = await invoke('get_config', { key: 'bb-welcomed' }); | |
| 26 | - | if (!welcomed) { | |
| 27 | - | showWelcome(); | |
| 29 | + | // Load data — catch so a backend error doesn't leave a blank screen | |
| 30 | + | try { | |
| 31 | + | await BB.sources.load(); | |
| 32 | + | await BB.items.load(); | |
| 33 | + | } catch (err) { | |
| 34 | + | BB.ui.showErrorWithRetry( | |
| 35 | + | 'Failed to load data: ' + BB.utils.getErrorMessage(err), | |
| 36 | + | () => { BB.sources.load(); BB.items.load(); } | |
| 37 | + | ); | |
| 28 | 38 | } | |
| 29 | 39 | ||
| 30 | - | // Search input — debounced at 300ms to avoid hammering the backend | |
| 31 | - | // on every keystroke while still feeling responsive. | |
| 40 | + | // First-run welcome | |
| 41 | + | try { | |
| 42 | + | const welcomed = await invoke('get_config', { key: 'bb-welcomed' }); | |
| 43 | + | if (!welcomed) { | |
| 44 | + | showWelcome(); | |
| 45 | + | } | |
| 46 | + | } catch (_) { | |
| 47 | + | // Non-critical — skip welcome on error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Search input — debounced at 300ms, with request ID so last request wins | |
| 32 | 51 | const searchInput = document.getElementById('search-input'); | |
| 33 | 52 | const searchSpinner = document.getElementById('search-spinner'); | |
| 34 | 53 | searchInput.addEventListener('input', BB.utils.debounce(async () => { | |
| 35 | 54 | BB.state.set('currentSearch', searchInput.value); | |
| 36 | 55 | BB.state.resetPagination(); | |
| 56 | + | const myId = ++loadRequestId; | |
| 37 | 57 | searchSpinner.classList.add('active'); | |
| 38 | 58 | await BB.items.load(); | |
| 39 | - | searchSpinner.classList.remove('active'); | |
| 59 | + | if (loadRequestId === myId) { | |
| 60 | + | searchSpinner.classList.remove('active'); | |
| 61 | + | } | |
| 40 | 62 | }, 300)); | |
| 41 | 63 | ||
| 42 | - | // Sort select | |
| 43 | - | document.getElementById('sort-select').addEventListener('change', (e) => { | |
| 64 | + | // Sort select — guarded so rapid changes don't interleave results | |
| 65 | + | document.getElementById('sort-select').addEventListener('change', async (e) => { | |
| 44 | 66 | BB.state.set('currentOrder', e.target.value); | |
| 45 | 67 | BB.state.resetPagination(); | |
| 46 | - | BB.items.load(); | |
| 68 | + | ++loadRequestId; | |
| 69 | + | await BB.items.load(); | |
| 47 | 70 | }); | |
| 48 | 71 | ||
| 49 | 72 | // Button handlers | |
| @@ -51,6 +74,8 @@ | |||
| 51 | 74 | document.getElementById('add-feed-btn').addEventListener('click', BB.feeds.openAddFeed); | |
| 52 | 75 | document.getElementById('close-detail').addEventListener('click', BB.detail.close); | |
| 53 | 76 | document.getElementById('load-more-btn').addEventListener('click', BB.items.loadMore); | |
| 77 | + | document.getElementById('settings-btn').addEventListener('click', showSettings); | |
| 78 | + | document.getElementById('sync-settings-btn').addEventListener('click', BB.sync.openSettings); | |
| 54 | 79 | ||
| 55 | 80 | // Modal close on overlay click | |
| 56 | 81 | document.getElementById('modal-overlay').addEventListener('click', (e) => { | |
| @@ -155,7 +180,26 @@ | |||
| 155 | 180 | ||
| 156 | 181 | listen('auto-fetch-error', (event) => { | |
| 157 | 182 | const pluginId = event.payload?.pluginId || 'unknown'; | |
| 158 | - | BB.ui.showToast('Failed to fetch ' + pluginId, 'error'); | |
| 183 | + | const error = event.payload?.error || ''; | |
| 184 | + | BB.ui.showToast('Failed to fetch ' + pluginId + (error ? ': ' + error : ''), 'error'); | |
| 185 | + | }); | |
| 186 | + | ||
| 187 | + | listen('feed-circuit-broken', (event) => { | |
| 188 | + | const pluginId = event.payload?.pluginId || 'unknown'; | |
| 189 | + | BB.ui.showErrorWithRetry( | |
| 190 | + | 'Feed "' + pluginId + '" disabled after repeated failures', | |
| 191 | + | async () => { | |
| 192 | + | try { | |
| 193 | + | await BB.api.feeds.resetCircuitBreaker(pluginId); | |
| 194 | + | BB.ui.showToast('Feed re-enabled'); | |
| 195 | + | BB.sources.load(); | |
| 196 | + | BB.items.load(); | |
| 197 | + | } catch (err) { | |
| 198 | + | BB.ui.showToast('Failed to reset: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ); | |
| 202 | + | BB.sources.load(); | |
| 159 | 203 | }); | |
| 160 | 204 | ||
| 161 | 205 | listen('menu:refresh', () => BB.feeds.refresh()); | |
| @@ -187,18 +231,31 @@ | |||
| 187 | 231 | const body = document.getElementById('modal-body'); | |
| 188 | 232 | const title = document.getElementById('modal-title'); | |
| 189 | 233 | title.textContent = 'Settings'; | |
| 190 | - | body.innerHTML = ` | |
| 191 | - | <div class="settings-content"> | |
| 192 | - | <div class="form-group"> | |
| 193 | - | <label>Theme</label> | |
| 194 | - | <div id="settings-theme-container"></div> | |
| 195 | - | </div> | |
| 196 | - | <div class="form-actions"> | |
| 197 | - | <button class="btn" onclick="BB.ui.closeModal();">Close</button> | |
| 198 | - | </div> | |
| 199 | - | </div> | |
| 200 | - | `; | |
| 201 | - | BB.themes.buildSelector(document.getElementById('settings-theme-container')); | |
| 234 | + | body.innerHTML = ''; | |
| 235 | + | const content = document.createElement('div'); | |
| 236 | + | content.className = 'settings-content'; | |
| 237 | + | ||
| 238 | + | const group = document.createElement('div'); | |
| 239 | + | group.className = 'form-group'; | |
| 240 | + | const label = document.createElement('label'); | |
| 241 | + | label.textContent = 'Theme'; | |
| 242 | + | const themeContainer = document.createElement('div'); | |
| 243 | + | themeContainer.id = 'settings-theme-container'; | |
| 244 | + | group.appendChild(label); | |
| 245 | + | group.appendChild(themeContainer); | |
| 246 | + | content.appendChild(group); | |
| 247 | + | ||
| 248 | + | const actions = document.createElement('div'); | |
| 249 | + | actions.className = 'form-actions'; | |
| 250 | + | const closeBtn = document.createElement('button'); | |
| 251 | + | closeBtn.className = 'btn'; | |
| 252 | + | closeBtn.textContent = 'Close'; | |
| 253 | + | closeBtn.addEventListener('click', BB.ui.closeModal); | |
| 254 | + | actions.appendChild(closeBtn); | |
| 255 | + | content.appendChild(actions); | |
| 256 | + | ||
| 257 | + | body.appendChild(content); | |
| 258 | + | BB.themes.buildSelector(themeContainer); | |
| 202 | 259 | BB.ui.openModal(); | |
| 203 | 260 | } | |
| 204 | 261 | ||
| @@ -240,21 +297,33 @@ | |||
| 240 | 297 | const body = document.getElementById('modal-body'); | |
| 241 | 298 | const title = document.getElementById('modal-title'); | |
| 242 | 299 | title.textContent = 'Welcome to Balanced Breakfast'; | |
| 243 | - | body.innerHTML = ` | |
| 244 | - | <div class="welcome-content"> | |
| 245 | - | <p>Your personal feed reader for RSS, Atom, podcasts, and more.</p> | |
| 246 | - | <h3>Getting Started</h3> | |
| 247 | - | <p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File > Import OPML</strong>.</p> | |
| 248 | - | <h3>Keyboard Shortcuts</h3> | |
| 249 | - | <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> | |
| 250 | - | <h3>Themes & Settings</h3> | |
| 251 | - | <p>Click the gear icon (<strong>⚙</strong>) in the header to change themes and configure preferences.</p> | |
| 252 | - | <div class="welcome-cta"> | |
| 253 | - | <button class="btn btn-success" onclick="BB.feeds.openAddFeed();">Add Your First Feed</button> | |
| 254 | - | <button class="btn" onclick="BB.ui.closeModal();">Explore First</button> | |
| 255 | - | </div> | |
| 256 | - | </div> | |
| 257 | - | `; | |
| 300 | + | body.innerHTML = ''; | |
| 301 | + | const content = document.createElement('div'); | |
| 302 | + | content.className = 'welcome-content'; | |
| 303 | + | content.innerHTML = | |
| 304 | + | '<p>Your personal feed reader for RSS, Atom, podcasts, and more.</p>' + | |
| 305 | + | '<h3>Getting Started</h3>' + | |
| 306 | + | '<p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File > Import OPML</strong>.</p>' + | |
| 307 | + | '<h3>Keyboard Shortcuts</h3>' + | |
| 308 | + | '<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>' + | |
| 309 | + | '<h3>Themes & Settings</h3>' + | |
| 310 | + | '<p>Click the gear icon (<strong>⚙</strong>) in the header to change themes and configure preferences.</p>'; | |
| 311 | + | ||
| 312 | + | const cta = document.createElement('div'); | |
| 313 | + | cta.className = 'welcome-cta'; | |
| 314 | + | const addBtn = document.createElement('button'); | |
| 315 | + | addBtn.className = 'btn btn-success'; | |
| 316 | + | addBtn.textContent = 'Add Your First Feed'; | |
| 317 | + | addBtn.addEventListener('click', BB.feeds.openAddFeed); | |
| 318 | + | const exploreBtn = document.createElement('button'); | |
| 319 | + | exploreBtn.className = 'btn'; | |
| 320 | + | exploreBtn.textContent = 'Explore First'; | |
| 321 | + | exploreBtn.addEventListener('click', BB.ui.closeModal); | |
| 322 | + | cta.appendChild(addBtn); | |
| 323 | + | cta.appendChild(exploreBtn); | |
| 324 | + | content.appendChild(cta); | |
| 325 | + | ||
| 326 | + | body.appendChild(content); | |
| 258 | 327 | BB.ui.openModal(); | |
| 259 | 328 | invoke('set_config', { key: 'bb-welcomed', value: '1' }); | |
| 260 | 329 | } |
| @@ -151,6 +151,9 @@ | |||
| 151 | 151 | ||
| 152 | 152 | form.onsubmit = async (e) => { | |
| 153 | 153 | e.preventDefault(); | |
| 154 | + | submitBtn.disabled = true; | |
| 155 | + | const savedLabel = submitBtn.textContent; | |
| 156 | + | submitBtn.textContent = 'Saving...'; | |
| 154 | 157 | const formData = new FormData(form); | |
| 155 | 158 | const data = {}; | |
| 156 | 159 | for (const [k, v] of formData.entries()) { | |
| @@ -161,7 +164,9 @@ | |||
| 161 | 164 | await opts.onSubmit(data); | |
| 162 | 165 | closeModal(); | |
| 163 | 166 | } catch (err) { | |
| 164 | - | showToast('Error: ' + (err.message || err), 'error'); | |
| 167 | + | submitBtn.disabled = false; | |
| 168 | + | submitBtn.textContent = savedLabel; | |
| 169 | + | showToast('Error: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 165 | 170 | } | |
| 166 | 171 | } | |
| 167 | 172 | }; |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | (function() { | |
| 12 | 12 | 'use strict'; | |
| 13 | 13 | ||
| 14 | - | const { escapeHtml, sanitizeHtml } = BB.utils; | |
| 14 | + | const { escapeHtml, sanitizeHtml, getErrorMessage } = BB.utils; | |
| 15 | 15 | ||
| 16 | 16 | /** @type {Object|null} Currently displayed item (mutable local copy). */ | |
| 17 | 17 | let currentItem = null; | |
| @@ -21,8 +21,10 @@ | |||
| 21 | 21 | * @param {string} id - Item external_id. | |
| 22 | 22 | */ | |
| 23 | 23 | async function load(id) { | |
| 24 | + | // Skip if already showing this item | |
| 25 | + | if (currentItem && currentItem.id === id) return; | |
| 26 | + | ||
| 24 | 27 | const panel = document.getElementById('detail-panel'); | |
| 25 | - | const detail = document.getElementById('item-detail'); | |
| 26 | 28 | ||
| 27 | 29 | try { | |
| 28 | 30 | const item = await BB.api.items.get(id); | |
| @@ -30,7 +32,7 @@ | |||
| 30 | 32 | panel.style.display = 'flex'; | |
| 31 | 33 | renderDetail(item); | |
| 32 | 34 | } catch (err) { | |
| 33 | - | BB.ui.showErrorWithRetry('Failed to load item: ' + err, () => load(id)); | |
| 35 | + | BB.ui.showErrorWithRetry('Failed to load item: ' + getErrorMessage(err), () => load(id)); | |
| 34 | 36 | } | |
| 35 | 37 | } | |
| 36 | 38 | ||
| @@ -47,7 +49,11 @@ | |||
| 47 | 49 | if (!currentItem) return; | |
| 48 | 50 | ||
| 49 | 51 | const updated = items.find(i => i.id === currentItem.id); | |
| 50 | - | if (!updated) return; | |
| 52 | + | if (!updated) { | |
| 53 | + | // Item was deleted or filtered out — close the stale detail panel | |
| 54 | + | close(); | |
| 55 | + | return; | |
| 56 | + | } | |
| 51 | 57 | ||
| 52 | 58 | // Merge summary-level fields that may have changed externally | |
| 53 | 59 | const summaryFields = [ | |
| @@ -86,17 +92,38 @@ | |||
| 86 | 92 | </div> | |
| 87 | 93 | ${tags} | |
| 88 | 94 | <div class="detail-body">${sanitizeHtml(item.body || item.text)}</div> | |
| 89 | - | <div class="detail-actions"> | |
| 90 | - | <button class="btn" onclick="BB.detail.toggleRead()"> | |
| 91 | - | ${item.isRead ? 'Mark Unread' : 'Mark Read'} | |
| 92 | - | </button> | |
| 93 | - | <button class="btn" onclick="BB.detail.toggleStar()"> | |
| 94 | - | ${item.isStarred ? 'Unstar' : 'Star'} | |
| 95 | - | </button> | |
| 96 | - | ${item.url ? `<button class="btn" onclick="BB.detail.readerView()">Reader View</button>` : ''} | |
| 97 | - | ${item.url ? `<button class="btn btn-primary" onclick="BB.detail.openUrl()">Open</button>` : ''} | |
| 98 | - | </div> | |
| 99 | 95 | `; | |
| 96 | + | ||
| 97 | + | const actions = document.createElement('div'); | |
| 98 | + | actions.className = 'detail-actions'; | |
| 99 | + | ||
| 100 | + | const readBtn = document.createElement('button'); | |
| 101 | + | readBtn.className = 'btn'; | |
| 102 | + | readBtn.textContent = item.isRead ? 'Mark Unread' : 'Mark Read'; | |
| 103 | + | readBtn.addEventListener('click', toggleRead); | |
| 104 | + | actions.appendChild(readBtn); | |
| 105 | + | ||
| 106 | + | const starBtn = document.createElement('button'); | |
| 107 | + | starBtn.className = 'btn'; | |
| 108 | + | starBtn.textContent = item.isStarred ? 'Unstar' : 'Star'; | |
| 109 | + | starBtn.addEventListener('click', toggleStar); | |
| 110 | + | actions.appendChild(starBtn); | |
| 111 | + | ||
| 112 | + | if (item.url) { | |
| 113 | + | const readerBtn = document.createElement('button'); | |
| 114 | + | readerBtn.className = 'btn'; | |
| 115 | + | readerBtn.textContent = 'Reader View'; | |
| 116 | + | readerBtn.addEventListener('click', readerView); | |
| 117 | + | actions.appendChild(readerBtn); | |
| 118 | + | ||
| 119 | + | const openBtn = document.createElement('button'); | |
| 120 | + | openBtn.className = 'btn btn-primary'; | |
| 121 | + | openBtn.textContent = 'Open'; | |
| 122 | + | openBtn.addEventListener('click', openUrl); | |
| 123 | + | actions.appendChild(openBtn); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | detail.appendChild(actions); | |
| 100 | 127 | } | |
| 101 | 128 | ||
| 102 | 129 | /** Close the detail panel and deselect the current item. */ | |
| @@ -113,19 +140,37 @@ | |||
| 113 | 140 | */ | |
| 114 | 141 | async function toggleRead() { | |
| 115 | 142 | if (!currentItem) return; | |
| 116 | - | const newState = !currentItem.isRead; | |
| 117 | - | await BB.items.toggleRead(currentItem.id, currentItem.isRead); | |
| 118 | - | currentItem.isRead = newState; | |
| 119 | - | renderDetail(currentItem); | |
| 143 | + | try { | |
| 144 | + | const wasRead = currentItem.isRead; | |
| 145 | + | if (wasRead) { | |
| 146 | + | await BB.api.items.markUnread(currentItem.id); | |
| 147 | + | } else { | |
| 148 | + | await BB.api.items.markRead(currentItem.id); | |
| 149 | + | } | |
| 150 | + | currentItem.isRead = !wasRead; | |
| 151 | + | BB.items.updateItemField(currentItem.id, 'isRead', !wasRead); | |
| 152 | + | renderDetail(currentItem); | |
| 153 | + | } catch (err) { | |
| 154 | + | BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); | |
| 155 | + | } | |
| 120 | 156 | } | |
| 121 | 157 | ||
| 122 | - | /** Toggle star on the current item (same optimistic-update pattern as toggleRead). */ | |
| 158 | + | /** Toggle star on the current item. Only mutates local state on success. */ | |
| 123 | 159 | async function toggleStar() { | |
| 124 | 160 | if (!currentItem) return; | |
| 125 | - | const newState = !currentItem.isStarred; | |
| 126 | - | await BB.items.toggleStar(currentItem.id, currentItem.isStarred); | |
| 127 | - | currentItem.isStarred = newState; | |
| 128 | - | renderDetail(currentItem); | |
| 161 | + | try { | |
| 162 | + | const wasStarred = currentItem.isStarred; | |
| 163 | + | if (wasStarred) { | |
| 164 | + | await BB.api.items.unstar(currentItem.id); | |
| 165 | + | } else { | |
| 166 | + | await BB.api.items.star(currentItem.id); | |
| 167 | + | } | |
| 168 | + | currentItem.isStarred = !wasStarred; | |
| 169 | + | BB.items.updateItemField(currentItem.id, 'isStarred', !wasStarred); | |
| 170 | + | renderDetail(currentItem); | |
| 171 | + | } catch (err) { | |
| 172 | + | BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); | |
| 173 | + | } | |
| 129 | 174 | } | |
| 130 | 175 | ||
| 131 | 176 | /** Open the current item's URL in the system browser via Tauri shell API. */ | |
| @@ -147,7 +192,7 @@ | |||
| 147 | 192 | if (result.title) currentItem.title = result.title; | |
| 148 | 193 | renderDetail(currentItem); | |
| 149 | 194 | } catch (err) { | |
| 150 | - | BB.ui.showToast('Reader view failed: ' + err, 'error'); | |
| 195 | + | BB.ui.showToast('Reader view failed: ' + getErrorMessage(err), 'error'); | |
| 151 | 196 | } | |
| 152 | 197 | } | |
| 153 | 198 |
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | body.appendChild(list); | |
| 47 | 47 | overlay.style.display = 'flex'; | |
| 48 | 48 | } catch (err) { | |
| 49 | - | BB.ui.showToast('Failed to load plugins: ' + err, 'error'); | |
| 49 | + | BB.ui.showToast('Failed to load plugins: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | 52 | ||
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | const schema = await BB.api.plugins.schema(pluginId); | |
| 62 | 62 | showPluginForm(schema); | |
| 63 | 63 | } catch (err) { | |
| 64 | - | BB.ui.showToast('Failed to load plugin schema: ' + err, 'error'); | |
| 64 | + | BB.ui.showToast('Failed to load plugin schema: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| @@ -135,7 +135,7 @@ | |||
| 135 | 135 | BB.items.load(); | |
| 136 | 136 | } catch (err) { | |
| 137 | 137 | progress.set(100); | |
| 138 | - | BB.ui.showErrorWithRetry('Failed to refresh: ' + err, refresh); | |
| 138 | + | BB.ui.showErrorWithRetry('Failed to refresh: ' + BB.utils.getErrorMessage(err), refresh); | |
| 139 | 139 | } finally { | |
| 140 | 140 | setTimeout(() => progress.remove(), 400); | |
| 141 | 141 | btn.disabled = false; | |
| @@ -158,7 +158,7 @@ | |||
| 158 | 158 | URL.revokeObjectURL(url); | |
| 159 | 159 | BB.ui.showToast('Feeds exported'); | |
| 160 | 160 | } catch (err) { | |
| 161 | - | BB.ui.showToast('Export failed: ' + err, 'error'); | |
| 161 | + | BB.ui.showToast('Export failed: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 162 | 162 | } | |
| 163 | 163 | } | |
| 164 | 164 | ||
| @@ -185,7 +185,7 @@ | |||
| 185 | 185 | BB.sources.load(); | |
| 186 | 186 | BB.items.load(); | |
| 187 | 187 | } catch (err) { | |
| 188 | - | BB.ui.showToast('Import failed: ' + err, 'error'); | |
| 188 | + | BB.ui.showToast('Import failed: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 189 | 189 | } | |
| 190 | 190 | }; | |
| 191 | 191 | input.click(); |
| @@ -7,7 +7,10 @@ | |||
| 7 | 7 | (function() { | |
| 8 | 8 | 'use strict'; | |
| 9 | 9 | ||
| 10 | - | const { escapeHtml, escapeAttr } = BB.utils; | |
| 10 | + | const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils; | |
| 11 | + | ||
| 12 | + | /** Set of item IDs with in-flight star/read toggles to prevent double-clicks. */ | |
| 13 | + | const inFlight = new Set(); | |
| 11 | 14 | ||
| 12 | 15 | /** | |
| 13 | 16 | * Fetch items from the backend and update state. | |
| @@ -39,7 +42,7 @@ | |||
| 39 | 42 | } | |
| 40 | 43 | } catch (err) { | |
| 41 | 44 | clearSkeletons(); | |
| 42 | - | BB.ui.showErrorWithRetry('Failed to load items: ' + err, () => load(append)); | |
| 45 | + | BB.ui.showErrorWithRetry('Failed to load items: ' + getErrorMessage(err), () => load(append)); | |
| 43 | 46 | } | |
| 44 | 47 | } | |
| 45 | 48 | ||
| @@ -120,7 +123,14 @@ | |||
| 120 | 123 | </div> | |
| 121 | 124 | `; | |
| 122 | 125 | ||
| 126 | + | li.setAttribute('tabindex', '0'); | |
| 123 | 127 | li.onclick = () => selectItem(item.id); | |
| 128 | + | li.onkeydown = (e) => { | |
| 129 | + | if (e.key === 'Enter' || e.key === ' ') { | |
| 130 | + | e.preventDefault(); | |
| 131 | + | selectItem(item.id); | |
| 132 | + | } | |
| 133 | + | }; | |
| 124 | 134 | list.appendChild(li); | |
| 125 | 135 | }); | |
| 126 | 136 | ||
| @@ -152,6 +162,8 @@ | |||
| 152 | 162 | * @param {boolean} isStarred - Current starred state. | |
| 153 | 163 | */ | |
| 154 | 164 | async function toggleStar(id, isStarred) { | |
| 165 | + | if (inFlight.has('star-' + id)) return; | |
| 166 | + | inFlight.add('star-' + id); | |
| 155 | 167 | try { | |
| 156 | 168 | if (isStarred) { | |
| 157 | 169 | await BB.api.items.unstar(id); | |
| @@ -161,7 +173,9 @@ | |||
| 161 | 173 | updateItemField(id, 'isStarred', !isStarred); | |
| 162 | 174 | BB.ui.showToast(isStarred ? 'Unstarred' : 'Starred'); | |
| 163 | 175 | } catch (err) { | |
| 164 | - | BB.ui.showToast('Failed to update star: ' + err, 'error'); | |
| 176 | + | BB.ui.showToast('Failed to update star: ' + getErrorMessage(err), 'error'); | |
| 177 | + | } finally { | |
| 178 | + | inFlight.delete('star-' + id); | |
| 165 | 179 | } | |
| 166 | 180 | } | |
| 167 | 181 | ||
| @@ -171,6 +185,8 @@ | |||
| 171 | 185 | * @param {boolean} isRead - Current read state. | |
| 172 | 186 | */ | |
| 173 | 187 | async function toggleRead(id, isRead) { | |
| 188 | + | if (inFlight.has('read-' + id)) return; | |
| 189 | + | inFlight.add('read-' + id); | |
| 174 | 190 | try { | |
| 175 | 191 | if (isRead) { | |
| 176 | 192 | await BB.api.items.markUnread(id); | |
| @@ -180,7 +196,9 @@ | |||
| 180 | 196 | updateItemField(id, 'isRead', !isRead); | |
| 181 | 197 | BB.ui.showToast(isRead ? 'Marked unread' : 'Marked read'); | |
| 182 | 198 | } catch (err) { | |
| 183 | - | BB.ui.showToast('Failed to update: ' + err, 'error'); | |
| 199 | + | BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); | |
| 200 | + | } finally { | |
| 201 | + | inFlight.delete('read-' + id); | |
| 184 | 202 | } | |
| 185 | 203 | } | |
| 186 | 204 | ||
| @@ -200,5 +218,5 @@ | |||
| 200 | 218 | ||
| 201 | 219 | BB.state.subscribe('items', render); | |
| 202 | 220 | ||
| 203 | - | BB.items = { load, render, selectItem, toggleStar, toggleRead, loadMore }; | |
| 221 | + | BB.items = { load, render, selectItem, toggleStar, toggleRead, loadMore, updateItemField }; | |
| 204 | 222 | })(); |
| @@ -182,7 +182,7 @@ | |||
| 182 | 182 | load(); | |
| 183 | 183 | BB.sources.load(); | |
| 184 | 184 | } catch (err) { | |
| 185 | - | BB.ui.showToast('Failed to save: ' + err, 'error'); | |
| 185 | + | BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 186 | 186 | } | |
| 187 | 187 | } | |
| 188 | 188 | ||
| @@ -218,7 +218,7 @@ | |||
| 218 | 218 | load(); | |
| 219 | 219 | BB.sources.load(); | |
| 220 | 220 | } catch (err) { | |
| 221 | - | BB.ui.showToast('Failed to delete: ' + err, 'error'); | |
| 221 | + | BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 222 | 222 | } | |
| 223 | 223 | } | |
| 224 | 224 |