max / balanced_breakfast
19 files changed,
+383 insertions,
-160 deletions
| @@ -13,6 +13,9 @@ use crate::url_cleaner; | |||
| 13 | 13 | /// HTTP request timeout for plugin host functions. | |
| 14 | 14 | const HTTP_TIMEOUT: Duration = Duration::from_secs(15); | |
| 15 | 15 | ||
| 16 | + | /// User-Agent sent with all plugin HTTP requests. | |
| 17 | + | const USER_AGENT: &str = concat!("BalancedBreakfast/", env!("CARGO_PKG_VERSION"), " (feed reader)"); | |
| 18 | + | ||
| 16 | 19 | /// Maximum response body size (2 MB). Prevents a plugin from consuming | |
| 17 | 20 | /// unbounded memory on a large or malicious response. | |
| 18 | 21 | const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024; | |
| @@ -96,7 +99,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc< | |||
| 96 | 99 | check_request_limit(&counter)?; | |
| 97 | 100 | ||
| 98 | 101 | let response = ureq::get(url) | |
| 99 | - | .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)") | |
| 102 | + | .set("User-Agent", USER_AGENT) | |
| 100 | 103 | .timeout(HTTP_TIMEOUT) | |
| 101 | 104 | .call() | |
| 102 | 105 | .map_err(|e| format!("HTTP request failed: {}", e))?; | |
| @@ -119,7 +122,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc< | |||
| 119 | 122 | check_request_limit(&counter)?; | |
| 120 | 123 | ||
| 121 | 124 | let response = ureq::get(url) | |
| 122 | - | .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)") | |
| 125 | + | .set("User-Agent", USER_AGENT) | |
| 123 | 126 | .timeout(HTTP_TIMEOUT) | |
| 124 | 127 | .call() | |
| 125 | 128 | .map_err(|e| format!("HTTP request failed: {}", e))?; |
| @@ -270,13 +270,13 @@ pub struct ReaderResult { | |||
| 270 | 270 | ||
| 271 | 271 | /// Run the reader extraction plugin on a URL. | |
| 272 | 272 | /// | |
| 273 | - | /// Creates a one-off Rhai engine, loads `plugins/reader.rhai`, and calls | |
| 274 | - | /// `extract(url)`. Returns the extracted article title, HTML content, and | |
| 275 | - | /// plain text. | |
| 276 | - | pub fn run_reader_script(url: &str) -> Result<ReaderResult, RhaiPluginError> { | |
| 273 | + | /// Creates a one-off Rhai engine, loads `reader.rhai` from the given plugins | |
| 274 | + | /// directory, and calls `extract(url)`. Returns the extracted article title, | |
| 275 | + | /// HTML content, and plain text. | |
| 276 | + | pub fn run_reader_script(url: &str, plugins_dir: &Path) -> Result<ReaderResult, RhaiPluginError> { | |
| 277 | 277 | let engine = create_engine(); | |
| 278 | 278 | ||
| 279 | - | let plugin_path = std::path::Path::new("plugins/reader.rhai"); | |
| 279 | + | let plugin_path = plugins_dir.join("reader.rhai"); | |
| 280 | 280 | let script = std::fs::read_to_string(plugin_path).map_err(|e| { | |
| 281 | 281 | RhaiPluginError::CompileError(format!("Failed to read reader plugin: {}", e)) | |
| 282 | 282 | })?; |
| @@ -91,7 +91,7 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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 @@ body { | |||
| 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); } |
| @@ -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> |
| @@ -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(); | |
| 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 | + | ); | |
| 38 | + | } | |
| 23 | 39 | ||
| 24 | 40 | // First-run welcome | |
| 25 | - | const welcomed = await invoke('get_config', { key: 'bb-welcomed' }); | |
| 26 | - | if (!welcomed) { | |
| 27 | - | showWelcome(); | |
| 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 | |
| 28 | 48 | } | |
| 29 | 49 | ||
| 30 | - | // Search input — debounced at 300ms to avoid hammering the backend | |
| 31 | - | // on every keystroke while still feeling responsive. | |
| 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 |
| @@ -347,6 +347,7 @@ | |||
| 347 | 347 | window.__TAURI__.event.listen('sync:changes-applied', () => { | |
| 348 | 348 | BB.ui.showToast('Sync: changes applied', 'success'); | |
| 349 | 349 | if (BB.sources && BB.sources.load) BB.sources.load(); | |
| 350 | + | if (BB.items && BB.items.load) BB.items.load(); | |
| 350 | 351 | }); | |
| 351 | 352 | } | |
| 352 | 353 |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | (function() { | |
| 8 | 8 | 'use strict'; | |
| 9 | 9 | ||
| 10 | - | const { escapeHtml } = BB.utils; | |
| 10 | + | const { escapeHtml, getErrorMessage } = BB.utils; | |
| 11 | 11 | ||
| 12 | 12 | /** Fetch the source list, all tags, and query feeds from the backend. */ | |
| 13 | 13 | async function load() { | |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | // Load query feeds in parallel (non-blocking). | |
| 22 | 22 | BB.queryFeeds.load(); | |
| 23 | 23 | } catch (err) { | |
| 24 | - | BB.ui.showErrorWithRetry('Failed to load sources: ' + err, load); | |
| 24 | + | BB.ui.showErrorWithRetry('Failed to load sources: ' + getErrorMessage(err), load); | |
| 25 | 25 | } | |
| 26 | 26 | } | |
| 27 | 27 | ||
| @@ -46,14 +46,22 @@ | |||
| 46 | 46 | const totalUnread = sources.reduce((sum, s) => sum + s.unreadCount, 0); | |
| 47 | 47 | const allCountText = totalUnread > 0 ? `${totalUnread}/${totalCount}` : `${totalCount}`; | |
| 48 | 48 | ||
| 49 | - | list.innerHTML = ` | |
| 50 | - | <li class="source-item${current === '' ? ' active' : ''}" data-source="" | |
| 51 | - | role="option" aria-selected="${current === ''}" tabindex="0" | |
| 52 | - | onclick="BB.sources.select('')"> | |
| 53 | - | <span class="source-name">All</span> | |
| 54 | - | <span class="source-count">${allCountText}</span> | |
| 55 | - | </li> | |
| 56 | - | `; | |
| 49 | + | list.innerHTML = ''; | |
| 50 | + | const allLi = document.createElement('li'); | |
| 51 | + | allLi.className = 'source-item' + (current === '' ? ' active' : ''); | |
| 52 | + | allLi.dataset.source = ''; | |
| 53 | + | allLi.setAttribute('role', 'option'); | |
| 54 | + | allLi.setAttribute('aria-selected', current === '' ? 'true' : 'false'); | |
| 55 | + | allLi.setAttribute('tabindex', '0'); | |
| 56 | + | allLi.innerHTML = '<span class="source-name">All</span><span class="source-count">' + allCountText + '</span>'; | |
| 57 | + | allLi.addEventListener('click', () => BB.sources.select('')); | |
| 58 | + | allLi.addEventListener('keydown', (e) => { | |
| 59 | + | if (e.key === 'Enter' || e.key === ' ') { | |
| 60 | + | e.preventDefault(); | |
| 61 | + | BB.sources.select(''); | |
| 62 | + | } | |
| 63 | + | }); | |
| 64 | + | list.appendChild(allLi); | |
| 57 | 65 | ||
| 58 | 66 | sources.forEach(source => { | |
| 59 | 67 | const li = document.createElement('li'); | |
| @@ -172,16 +180,6 @@ | |||
| 172 | 180 | } | |
| 173 | 181 | list.appendChild(addQfBtn); | |
| 174 | 182 | ||
| 175 | - | // Add keyboard handler for the "All" entry | |
| 176 | - | const allItem = list.querySelector('[data-source=""]'); | |
| 177 | - | if (allItem) { | |
| 178 | - | allItem.onkeydown = (e) => { | |
| 179 | - | if (e.key === 'Enter' || e.key === ' ') { | |
| 180 | - | e.preventDefault(); | |
| 181 | - | BB.sources.select(''); | |
| 182 | - | } | |
| 183 | - | }; | |
| 184 | - | } | |
| 185 | 183 | } | |
| 186 | 184 | ||
| 187 | 185 | /** | |
| @@ -191,6 +189,10 @@ | |||
| 191 | 189 | function select(sourceId) { | |
| 192 | 190 | BB.state.set('currentQueryFeed', null); | |
| 193 | 191 | BB.state.set('currentSource', sourceId); | |
| 192 | + | // Clear search when switching sources so results aren't unexpectedly filtered | |
| 193 | + | BB.state.set('currentSearch', ''); | |
| 194 | + | const searchInput = document.getElementById('search-input'); | |
| 195 | + | if (searchInput) searchInput.value = ''; | |
| 194 | 196 | BB.state.resetPagination(true); | |
| 195 | 197 | render(BB.state.sources); | |
| 196 | 198 | BB.items.load(); | |
| @@ -234,7 +236,7 @@ | |||
| 234 | 236 | load(); | |
| 235 | 237 | BB.items.load(); | |
| 236 | 238 | } catch (err) { | |
| 237 | - | BB.ui.showToast('Failed to restore feed: ' + err, 'error'); | |
| 239 | + | BB.ui.showToast('Failed to restore feed: ' + getErrorMessage(err), 'error'); | |
| 238 | 240 | } | |
| 239 | 241 | }, | |
| 240 | 242 | }, | |
| @@ -248,7 +250,7 @@ | |||
| 248 | 250 | load(); | |
| 249 | 251 | BB.items.load(); | |
| 250 | 252 | } catch (err) { | |
| 251 | - | BB.ui.showToast('Failed to delete feed: ' + err, 'error'); | |
| 253 | + | BB.ui.showToast('Failed to delete feed: ' + getErrorMessage(err), 'error'); | |
| 252 | 254 | } | |
| 253 | 255 | } | |
| 254 | 256 | ||
| @@ -331,7 +333,7 @@ | |||
| 331 | 333 | BB.api.plugins.schema(source.id), | |
| 332 | 334 | ]); | |
| 333 | 335 | } catch (err) { | |
| 334 | - | BB.ui.showToast('Failed to load feed details: ' + err, 'error'); | |
| 336 | + | BB.ui.showToast('Failed to load feed details: ' + getErrorMessage(err), 'error'); | |
| 335 | 337 | return; | |
| 336 | 338 | } | |
| 337 | 339 |
| @@ -434,8 +434,8 @@ describe('BB.sources.render', () => { | |||
| 434 | 434 | BB.state.set('queryFeeds', []); | |
| 435 | 435 | BB.sources.render(sources); | |
| 436 | 436 | const list = document.getElementById('sources-list'); | |
| 437 | - | // 3 source items + 1 "+ Query Feed" button appended via appendChild | |
| 438 | - | assertEqual(list.children.length, 4); | |
| 437 | + | // 1 All + 3 source items + 1 "+ Query Feed" button | |
| 438 | + | assertEqual(list.children.length, 5); | |
| 439 | 439 | }); | |
| 440 | 440 | ||
| 441 | 441 | test('source health indicator shows correct class for yellow', () => { | |
| @@ -447,7 +447,7 @@ describe('BB.sources.render', () => { | |||
| 447 | 447 | BB.state.set('queryFeeds', []); | |
| 448 | 448 | BB.sources.render(sources); | |
| 449 | 449 | const list = document.getElementById('sources-list'); | |
| 450 | - | const sourceItem = list.children[0]; // first appended child is the source | |
| 450 | + | const sourceItem = list.children[1]; // children[0] is "All", sources start at [1] | |
| 451 | 451 | assert(sourceItem.innerHTML.includes('health-yellow'), 'Should have health-yellow class'); | |
| 452 | 452 | }); | |
| 453 | 453 | ||
| @@ -460,7 +460,7 @@ describe('BB.sources.render', () => { | |||
| 460 | 460 | BB.state.set('queryFeeds', []); | |
| 461 | 461 | BB.sources.render(sources); | |
| 462 | 462 | const list = document.getElementById('sources-list'); | |
| 463 | - | const sourceItem = list.children[0]; | |
| 463 | + | const sourceItem = list.children[1]; // children[0] is "All" | |
| 464 | 464 | assert(sourceItem.innerHTML.includes('>5<'), 'Should show just total count'); | |
| 465 | 465 | assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format'); | |
| 466 | 466 | }); | |
| @@ -474,7 +474,7 @@ describe('BB.sources.render', () => { | |||
| 474 | 474 | BB.state.set('queryFeeds', []); | |
| 475 | 475 | BB.sources.render(sources); | |
| 476 | 476 | const list = document.getElementById('sources-list'); | |
| 477 | - | const sourceItem = list.children[0]; | |
| 477 | + | const sourceItem = list.children[1]; // children[0] is "All" | |
| 478 | 478 | assert(sourceItem.innerHTML.includes('dns fail'), 'Should include error text'); | |
| 479 | 479 | }); | |
| 480 | 480 | ||
| @@ -484,9 +484,9 @@ describe('BB.sources.render', () => { | |||
| 484 | 484 | BB.state.set('queryFeeds', []); | |
| 485 | 485 | BB.sources.render([]); | |
| 486 | 486 | const list = document.getElementById('sources-list'); | |
| 487 | - | assert(list.innerHTML.includes('All'), 'Should have All entry'); | |
| 488 | - | // Only the "+ Query Feed" button is appended via appendChild | |
| 489 | - | assertEqual(list.children.length, 1); | |
| 487 | + | // All item + "+ Query Feed" button | |
| 488 | + | assertEqual(list.children.length, 2); | |
| 489 | + | assert(list.children[0].innerHTML.includes('All'), 'Should have All entry'); | |
| 490 | 490 | }); | |
| 491 | 491 | }); | |
| 492 | 492 |
| @@ -82,9 +82,8 @@ | |||
| 82 | 82 | root.style.setProperty('--border-dark', darken(border, 10)); | |
| 83 | 83 | ||
| 84 | 84 | // Derived: shadow from foreground.primary at opacity | |
| 85 | - | const fg = hexToRgb(colors['foreground.primary']); | |
| 86 | - | root.style.setProperty('--shadow', `rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.08)`); | |
| 87 | - | root.style.setProperty('--shadow-hover', `rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.12)`); | |
| 85 | + | root.style.setProperty('--shadow', `color-mix(in srgb, ${colors['foreground.primary']} 8%, transparent)`); | |
| 86 | + | root.style.setProperty('--shadow-hover', `color-mix(in srgb, ${colors['foreground.primary']} 12%, transparent)`); | |
| 88 | 87 | } | |
| 89 | 88 | ||
| 90 | 89 | /** |
| @@ -25,24 +25,11 @@ | |||
| 25 | 25 | ||
| 26 | 26 | const banner = document.createElement('div'); | |
| 27 | 27 | banner.id = 'update-banner'; | |
| 28 | - | banner.style.cssText = [ | |
| 29 | - | 'position: fixed', | |
| 30 | - | 'bottom: 1rem', | |
| 31 | - | 'right: 1rem', | |
| 32 | - | 'background: var(--bg-secondary)', | |
| 33 | - | 'border: 1px solid var(--border)', | |
| 34 | - | 'border-radius: 8px', | |
| 35 | - | 'padding: 0.75rem 1rem', | |
| 36 | - | 'z-index: 9999', | |
| 37 | - | 'max-width: 320px', | |
| 38 | - | 'box-shadow: 0 4px 12px var(--shadow)', | |
| 39 | - | 'font-family: var(--font-body, sans-serif)', | |
| 40 | - | 'font-size: 0.875rem', | |
| 41 | - | ].join(';'); | |
| 28 | + | banner.className = 'update-banner'; | |
| 42 | 29 | ||
| 43 | 30 | const title = document.createElement('div'); | |
| 44 | 31 | title.style.cssText = 'font-weight: 600; margin-bottom: 0.25rem;'; | |
| 45 | - | title.textContent = 'Update Available: v' + BB.utils.escapeHtml(version); | |
| 32 | + | title.textContent = 'Update Available: v' + version; | |
| 46 | 33 | banner.appendChild(title); | |
| 47 | 34 | ||
| 48 | 35 | if (body) { | |
| @@ -81,7 +68,7 @@ | |||
| 81 | 68 | const { check } = window.__TAURI_PLUGIN_UPDATER__; | |
| 82 | 69 | pendingUpdate = await check(); | |
| 83 | 70 | } catch (err) { | |
| 84 | - | BB.ui.showToast('Update check failed: ' + err, 'error'); | |
| 71 | + | BB.ui.showToast('Update check failed: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 85 | 72 | return; | |
| 86 | 73 | } | |
| 87 | 74 | } | |
| @@ -128,7 +115,7 @@ | |||
| 128 | 115 | BB.ui.showToast('You are running the latest version.'); | |
| 129 | 116 | } | |
| 130 | 117 | } catch (err) { | |
| 131 | - | BB.ui.showToast('Update check failed: ' + err, 'error'); | |
| 118 | + | BB.ui.showToast('Update check failed: ' + BB.utils.getErrorMessage(err), 'error'); | |
| 132 | 119 | } | |
| 133 | 120 | }); | |
| 134 | 121 | } |
| @@ -69,10 +69,11 @@ | |||
| 69 | 69 | continue; | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | - | // Strip javascript: and data: URLs in href and src. | |
| 72 | + | // Strip dangerous URL schemes in href and src. | |
| 73 | + | // Normalize by removing whitespace/control chars to defeat obfuscation. | |
| 73 | 74 | if (lower === 'href' || lower === 'src') { | |
| 74 | - | const value = (el.getAttribute(name) || '').trim().toLowerCase(); | |
| 75 | - | if (value.startsWith('javascript:') || value.startsWith('data:')) { | |
| 75 | + | const value = (el.getAttribute(name) || '').replace(/[\s\x00-\x1f]/g, '').toLowerCase(); | |
| 76 | + | if (value.startsWith('javascript:') || value.startsWith('data:') || value.startsWith('vbscript:')) { | |
| 76 | 77 | el.removeAttribute(name); | |
| 77 | 78 | } | |
| 78 | 79 | } | |
| @@ -108,5 +109,22 @@ | |||
| 108 | 109 | }; | |
| 109 | 110 | } | |
| 110 | 111 | ||
| 111 | - | BB.utils = { escapeHtml, escapeAttr, sanitizeHtml, debounce }; | |
| 112 | + | /** | |
| 113 | + | * Extract a human-readable message from a Tauri API error. | |
| 114 | + | * Tauri commands that return Result<T, ApiError> reject with the serialized | |
| 115 | + | * ApiError object ({code, message}), not a plain string. | |
| 116 | + | * @param {any} err - Error from a rejected invoke() promise. | |
| 117 | + | * @returns {string} The error message string. | |
| 118 | + | */ | |
| 119 | + | function getErrorMessage(err) { | |
| 120 | + | if (!err) return 'Unknown error'; | |
| 121 | + | if (typeof err === 'string') return err; | |
| 122 | + | if (err.message) return err.message; | |
| 123 | + | if (typeof err === 'object') { | |
| 124 | + | try { return JSON.stringify(err); } catch (_) { /* fall through */ } | |
| 125 | + | } | |
| 126 | + | return String(err); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | BB.utils = { escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage }; | |
| 112 | 130 | })(); |
| @@ -190,12 +190,19 @@ pub struct ReaderViewResponse { | |||
| 190 | 190 | #[tauri::command] | |
| 191 | 191 | #[instrument(skip_all)] | |
| 192 | 192 | pub async fn extract_reader_view( | |
| 193 | - | _state: State<'_, Arc<AppState>>, | |
| 193 | + | state: State<'_, Arc<AppState>>, | |
| 194 | 194 | url: String, | |
| 195 | 195 | ) -> Result<ReaderViewResponse, ApiError> { | |
| 196 | + | let plugins_dir = state | |
| 197 | + | .orchestrator | |
| 198 | + | .plugins() | |
| 199 | + | .read() | |
| 200 | + | .await | |
| 201 | + | .plugins_dir() | |
| 202 | + | .to_path_buf(); | |
| 196 | 203 | // Run in a blocking task since Rhai engine and HTTP are synchronous. | |
| 197 | 204 | let result = tokio::task::spawn_blocking(move || { | |
| 198 | - | bb_core::rhai_plugin::run_reader_script(&url).map_err(|e| { | |
| 205 | + | bb_core::rhai_plugin::run_reader_script(&url, &plugins_dir).map_err(|e| { | |
| 199 | 206 | ApiError::plugin(format!("Reader extraction failed: {}", e)) | |
| 200 | 207 | }) | |
| 201 | 208 | }) |
| @@ -806,6 +806,7 @@ mod tests { | |||
| 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(); |