Skip to main content

max / balanced_breakfast

20.9 KB · 508 lines History Blame Raw
1 /**
2 * @fileoverview Sources sidebar: lists feed sources with unread counts.
3 *
4 * Each source is a busser_id. Selecting a source filters the items list.
5 * The "All" entry (empty source) shows all items across all sources.
6 */
7 (function() {
8 'use strict';
9
10 const { escapeHtml, getErrorMessage } = BB.utils;
11
12 /**
13 * Fetch the source list, all tags, and query feeds from the backend.
14 * @returns {Promise<void>}
15 */
16 async function load() {
17 try {
18 const [sources, allTags] = await Promise.all([
19 BB.api.sources.list(),
20 BB.api.feeds.listAllTags(),
21 ]);
22 BB.state.set('allTags', allTags);
23 BB.state.set('sources', sources);
24 // Load query feeds in parallel (non-blocking).
25 BB.queryFeeds.load();
26 } catch (err) {
27 BB.ui.showErrorWithRetry('Failed to load sources: ' + getErrorMessage(err), load);
28 }
29 }
30
31 /**
32 * Render the sources sidebar. Shows "All" with aggregate counts, then
33 * individual sources with per-source unread/total counts.
34 * @param {Array<Object>} sources - Source objects with id, name, totalCount, unreadCount.
35 */
36 function render(sources) {
37 const list = document.getElementById('sources-list');
38 const current = BB.state.currentSource;
39
40 // Set listbox role on the container for accessibility
41 list.setAttribute('role', 'listbox');
42 list.setAttribute('aria-label', 'Feed sources');
43
44 // Render tag filter bar above the sources list
45 renderTagBar();
46
47 // Total counts
48 const totalCount = sources.reduce((sum, s) => sum + s.totalCount, 0);
49 const totalUnread = sources.reduce((sum, s) => sum + s.unreadCount, 0);
50 const allReadClass = totalUnread === 0 && totalCount > 0 ? ' all-read' : '';
51 const allCountText = totalUnread > 0
52 ? `${totalUnread}/${totalCount}`
53 : totalCount > 0 ? `\u2713 ${totalCount}` : `${totalCount}`;
54
55 // Build all items in a fragment first, then swap in one operation
56 // to avoid the flash of empty content.
57 const frag = document.createDocumentFragment();
58
59 const allLi = document.createElement('li');
60 allLi.className = 'source-item' + (current === '' ? ' active' : '');
61 allLi.dataset.source = '';
62 allLi.setAttribute('role', 'option');
63 allLi.setAttribute('aria-selected', current === '' ? 'true' : 'false');
64 allLi.setAttribute('tabindex', '0');
65 allLi.innerHTML = '<span class="source-name">All</span><span class="source-count' + allReadClass + '">' + allCountText + '</span>';
66 allLi.addEventListener('click', () => BB.sources.select(''));
67 allLi.addEventListener('keydown', (e) => {
68 if (e.key === 'Enter' || e.key === ' ') {
69 e.preventDefault();
70 BB.sources.select('');
71 }
72 });
73 frag.appendChild(allLi);
74
75 // Empty-state onboarding when no feeds exist.
76 // F4 (2026-06-02): inline styles + broken `var(--yolk)` token
77 // retired in favor of .source-item--empty + .source-item-empty-*
78 // classes.
79 if (sources.length === 0) {
80 const emptyLi = document.createElement('li');
81 emptyLi.className = 'source-item source-item--empty';
82 emptyLi.innerHTML = '<div class="source-item-empty-icon">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong class="source-item-empty-strong">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" class="source-item-empty-link">import an OPML file</a>.';
83 frag.appendChild(emptyLi);
84 }
85
86 sources.forEach(source => {
87 const li = document.createElement('li');
88 li.className = 'source-item' + (current === source.id ? ' active' : '');
89 li.dataset.source = source.id;
90 li.setAttribute('role', 'option');
91 li.setAttribute('aria-selected', current === source.id ? 'true' : 'false');
92 li.setAttribute('tabindex', '0');
93 li.onclick = () => BB.sources.select(source.id);
94 li.onkeydown = (e) => {
95 if (e.key === 'Enter' || e.key === ' ') {
96 e.preventDefault();
97 BB.sources.select(source.id);
98 }
99 };
100
101 const srcReadClass = source.unreadCount === 0 && source.totalCount > 0 ? ' all-read' : '';
102 const countText = source.unreadCount > 0
103 ? `${source.unreadCount}/${source.totalCount}`
104 : source.totalCount > 0 ? `\u2713 ${source.totalCount}` : `${source.totalCount}`;
105
106 const healthLabels = {
107 yellow: 'Feed has warnings',
108 red: 'Feed has errors',
109 circuit_broken: 'Feed disabled by circuit breaker',
110 auth_error: 'Feed authentication failed',
111 config_error: 'Feed configuration error',
112 rate_limited: 'Feed rate-limited; will retry',
113 };
114 const healthDot = source.health && source.health !== 'green'
115 // F3: state-by-color paired with shape via [data-health] CSS
116 // rules + aria-label for screen readers. F5: dropped the
117 // legacy `health-${health}` class (now handled by [data-health]).
118 ? `<span class="health-dot" data-health="${escapeHtml(source.health)}" aria-label="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed status')}" title="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed has errors')}"></span>`
119 : '';
120
121 const tagChips = (source.tags || [])
122 .map(t => `<span class="source-tag-chip">${escapeHtml(t)}</span>`)
123 .join('');
124
125 li.innerHTML = `
126 ${healthDot}
127 <div class="source-info">
128 <span class="source-name">${escapeHtml(source.name)}</span>
129 ${tagChips ? `<div class="source-tags">${tagChips}</div>` : ''}
130 </div>
131 <span class="source-actions">
132 <span class="source-count${srcReadClass}">${countText}</span>
133 <button class="source-edit" title="Feed settings" aria-label="Feed settings">&#9881;</button>
134 </span>
135 `;
136
137 li.querySelector('.source-edit').onclick = (e) => {
138 e.stopPropagation();
139 showFeedPopover(source, e.currentTarget);
140 };
141
142 frag.appendChild(li);
143 });
144
145 // Render query feeds section
146 const queryFeeds = BB.state.queryFeeds || [];
147 if (queryFeeds.length > 0) {
148 const divider = document.createElement('li');
149 divider.className = 'source-divider';
150 divider.textContent = 'Saved Filters';
151 frag.appendChild(divider);
152
153 const currentQF = BB.state.currentQueryFeed;
154 queryFeeds.forEach(qf => {
155 const li = document.createElement('li');
156 li.className = 'source-item query-feed' + (currentQF === qf.id ? ' active' : '');
157 li.setAttribute('role', 'option');
158 li.setAttribute('aria-selected', currentQF === qf.id ? 'true' : 'false');
159 li.setAttribute('tabindex', '0');
160 li.onclick = () => BB.queryFeeds.select(qf.id);
161 li.onkeydown = (e) => {
162 if (e.key === 'Enter' || e.key === ' ') {
163 e.preventDefault();
164 BB.queryFeeds.select(qf.id);
165 }
166 };
167
168 li.innerHTML = `
169 <div class="source-info">
170 <span class="source-name">${escapeHtml(qf.name)}</span>
171 </div>
172 <span class="source-actions">
173 <span class="source-count">${escapeHtml(String(qf.matchCount))}</span>
174 <button class="source-edit" title="Edit query feed" aria-label="Edit query feed">&#9881;</button>
175 <button class="source-delete" title="Delete query feed" aria-label="Delete query feed">&times;</button>
176 </span>
177 `;
178
179 li.querySelector('.source-edit').onclick = (e) => {
180 e.stopPropagation();
181 BB.queryFeeds.openBuilder(qf);
182 };
183
184 li.querySelector('.source-delete').onclick = (e) => {
185 e.stopPropagation();
186 BB.queryFeeds.deleteFeed(qf);
187 };
188
189 frag.appendChild(li);
190 });
191 }
192
193 // "+ Saved Filter" button
194 const addQfBtn = document.createElement('li');
195 addQfBtn.className = 'source-item add-query-feed-btn';
196 addQfBtn.innerHTML = '<span class="source-name source-name--muted" title="Create a dynamic feed from search filters">+ Saved Filter</span>';
197 addQfBtn.onclick = () => BB.queryFeeds.openBuilder(null);
198 frag.appendChild(addQfBtn);
199
200 // Single DOM operation: clear and append
201 list.innerHTML = '';
202 list.appendChild(frag);
203
204 // Update pinned reading list entry
205 const savedBtn = document.getElementById('saved-articles-btn');
206 if (savedBtn) {
207 savedBtn.classList.toggle('active', current === '__saved__');
208 if (BB.bookmarks) BB.bookmarks.updateBadge();
209 }
210 }
211
212 /**
213 * Show a floating popover with health info and feed actions.
214 * @param {Object} source - Source object from the backend.
215 * @param {HTMLElement} anchor - The button element to anchor against.
216 */
217 function showFeedPopover(source, anchor) {
218 // Remove any existing popover
219 const old = document.getElementById('feed-popover');
220 if (old) old.remove();
221
222 const pop = document.createElement('div');
223 pop.id = 'feed-popover';
224 pop.className = 'health-popover';
225
226 // Health section
227 const statusMap = {
228 green: { label: 'Healthy', cls: 'hp-green' },
229 yellow: { label: 'Intermittent errors', cls: 'hp-yellow' },
230 red: { label: 'Failing', cls: 'hp-red' },
231 rate_limited: { label: 'Rate limited', cls: 'hp-yellow' },
232 auth_error: { label: 'Auth error', cls: 'hp-red' },
233 config_error: { label: 'Config error', cls: 'hp-red' },
234 circuit_broken: { label: 'Circuit broken', cls: 'hp-grey' },
235 };
236 const info = statusMap[source.health] || statusMap.green;
237
238 let rows = `<div class="hp-row"><span class="hp-label">Status</span><span class="hp-value ${info.cls}">${info.label}</span></div>`;
239
240 if (source.errorCategory && source.errorCategory !== 'unknown') {
241 rows += `<div class="hp-row"><span class="hp-label">Error type</span><span class="hp-value">${escapeHtml(source.errorCategory)}</span></div>`;
242 }
243
244 if (source.lastError) {
245 const msg = source.lastError.length > 120
246 ? source.lastError.slice(0, 120) + '\u2026'
247 : source.lastError;
248 rows += `<div class="hp-row hp-row-error"><span class="hp-label">Last error</span><span class="hp-value">${escapeHtml(msg)}</span></div>`;
249 }
250
251 if (source.retryAfterSecs) {
252 rows += `<div class="hp-row"><span class="hp-label">Retry after</span><span class="hp-value">${source.retryAfterSecs}s</span></div>`;
253 }
254
255 if (source.circuitBroken) {
256 rows += `<div class="hp-row"><span class="hp-label">Auto-fetch</span><span class="hp-value hp-red">Disabled</span></div>`;
257 }
258
259 // Action buttons
260 const actions = `<div class="hp-actions">
261 <button class="hp-action" data-action="mark-read">Mark All Read</button>
262 <button class="hp-action" data-action="edit">Edit Feed</button>
263 <button class="hp-action" data-action="tags">Edit Tags</button>
264 <button class="hp-action hp-action-danger" data-action="delete">Delete</button>
265 </div>`;
266
267 pop.innerHTML = `<div class="hp-title">${escapeHtml(source.name)}</div>${rows}${actions}`;
268 document.body.appendChild(pop);
269
270 // Position relative to the anchor button
271 const rect = anchor.getBoundingClientRect();
272 pop.style.top = (rect.bottom + 4) + 'px';
273 pop.style.left = Math.max(4, rect.left - 100) + 'px';
274
275 // Wire up action buttons
276 function dismiss() {
277 pop.remove();
278 document.removeEventListener('click', outsideClick, true);
279 }
280 pop.querySelector('[data-action="mark-read"]').onclick = async () => {
281 dismiss();
282 try {
283 await BB.api.items.markAllRead(source.id);
284 BB.ui.showToast('Marked all read in ' + source.name);
285 await BB.sources.load();
286 await BB.items.load();
287 } catch (err) {
288 BB.ui.showToast('Failed: ' + getErrorMessage(err), 'error');
289 }
290 };
291 pop.querySelector('[data-action="edit"]').onclick = () => { dismiss(); editFeed(source); };
292 pop.querySelector('[data-action="tags"]').onclick = () => { dismiss(); editTags(source); };
293 pop.querySelector('[data-action="delete"]').onclick = () => { dismiss(); deleteFeed(source); };
294
295 // Close on outside click
296 function outsideClick(e) {
297 if (!pop.contains(e.target) && e.target !== anchor) {
298 dismiss();
299 }
300 }
301 requestAnimationFrame(() => {
302 document.addEventListener('click', outsideClick, true);
303 });
304 }
305
306 /**
307 * Select a source: reset pagination, clear item selection, reload items,
308 * then update sidebar to reflect new active state.
309 * @param {string} sourceId - Busser ID to filter by, or '' for all.
310 */
311 async function select(sourceId) {
312 BB.detail.collapseReader();
313 BB.state.set('currentQueryFeed', null);
314 BB.state.set('currentSource', sourceId);
315 // Clear search when switching sources so results aren't unexpectedly filtered
316 BB.state.set('currentSearch', '');
317 const searchInput = document.getElementById('search-input');
318 if (searchInput) searchInput.value = '';
319 const mobileSearch = document.getElementById('mobile-search-input');
320 if (mobileSearch) mobileSearch.value = '';
321 BB.state.resetPagination(true);
322 await BB.items.load();
323 render(BB.state.sources);
324 BB.navigation.onSourceSelected(sourceId);
325 }
326
327 /**
328 * Delete a source and all its items after user confirmation.
329 * Shows an undo toast that can re-create the feed with the same config.
330 * @param {Object} source - Source object with `id` and `name`.
331 */
332 async function deleteFeed(source) {
333 const ok = await BB.ui.confirmAction('Delete "' + source.name + '" and all its items?');
334 if (!ok) return;
335
336 // Snapshot the full feed details (including config) before deleting
337 // so we can faithfully restore them on undo.
338 let feedSnapshots = null;
339 try {
340 feedSnapshots = await BB.api.feeds.getByBusser(source.id);
341 } catch (_) {
342 // If snapshot fails, proceed without undo capability
343 }
344
345 try {
346 await BB.api.feeds.deleteByBusser(source.id);
347
348 // Show undo toast if we have snapshot data
349 if (feedSnapshots && feedSnapshots.length > 0) {
350 BB.ui.showToast('Deleted ' + source.name, 'success', {
351 action: {
352 label: 'Undo',
353 fn: async () => {
354 try {
355 for (const snap of feedSnapshots) {
356 await BB.api.feeds.create({
357 busserId: snap.busserId,
358 name: snap.name,
359 config: snap.config,
360 });
361 }
362 BB.ui.showToast('Restored ' + source.name);
363 load();
364 BB.items.load();
365 } catch (err) {
366 BB.ui.showToast('Failed to restore feed: ' + getErrorMessage(err), 'error');
367 }
368 },
369 },
370 duration: 6000,
371 });
372 } else {
373 BB.ui.showToast('Deleted ' + source.name);
374 }
375
376 select('');
377 load();
378 BB.items.load();
379 } catch (err) {
380 BB.ui.showToast('Failed to delete feed: ' + getErrorMessage(err), 'error');
381 }
382 }
383
384 /** Render the horizontal tag filter bar above the sources list. */
385 function renderTagBar() {
386 const allTags = BB.state.allTags || [];
387 let bar = document.getElementById('tag-filter-bar');
388 if (!bar) {
389 bar = document.createElement('div');
390 bar.id = 'tag-filter-bar';
391 bar.className = 'tag-filter-bar';
392 const list = document.getElementById('sources-list');
393 list.parentNode.insertBefore(bar, list);
394 }
395 if (allTags.length === 0) {
396 bar.style.display = 'none';
397 return;
398 }
399 bar.style.display = 'flex';
400 const currentTag = BB.state.currentTag || '';
401 bar.innerHTML = '';
402
403 const allBtn = document.createElement('button');
404 allBtn.className = 'tag-chip' + (currentTag === '' ? ' active' : '');
405 allBtn.textContent = 'All';
406 allBtn.addEventListener('click', () => selectTag(''));
407 bar.appendChild(allBtn);
408
409 allTags.forEach(t => {
410 const btn = document.createElement('button');
411 btn.className = 'tag-chip' + (currentTag === t ? ' active' : '');
412 btn.textContent = t;
413 btn.addEventListener('click', () => selectTag(t));
414 bar.appendChild(btn);
415 });
416 }
417
418 /**
419 * Select a tag filter: reset pagination and reload items.
420 * @param {string} tag - Tag name to filter by, or '' for all.
421 */
422 function selectTag(tag) {
423 BB.state.set('currentTag', tag);
424 BB.state.resetPagination(true);
425 renderTagBar();
426 BB.items.load();
427 }
428
429 /**
430 * Open a form modal to edit tags on a source (comma-separated).
431 * @param {Object} source - Source object with `id` and `name`.
432 */
433 function editTags(source) {
434 const currentTags = (source.tags || []).join(', ');
435
436 BB.ui.openFormModal({
437 title: 'Edit Tags \u2014 ' + source.name,
438 fields: [
439 {
440 name: 'tags',
441 type: 'text',
442 label: 'Tags (comma-separated)',
443 value: currentTags,
444 placeholder: 'news, tech, daily',
445 },
446 ],
447 submitLabel: 'Save Tags',
448 onSubmit: async (data) => {
449 const tags = data.tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
450 await BB.api.feeds.setTags(source.id, tags);
451 BB.ui.showToast('Tags updated');
452 load();
453 },
454 });
455 }
456
457 /**
458 * Open a form modal to edit a feed's name and config fields.
459 * @param {Object} source - Source object with `id` and `name`.
460 */
461 async function editFeed(source) {
462 let feed, schema;
463 try {
464 [feed, schema] = await Promise.all([
465 BB.api.feeds.get(source.id),
466 BB.api.plugins.schema(source.id),
467 ]);
468 } catch (err) {
469 BB.ui.showToast('Failed to load feed details: ' + getErrorMessage(err), 'error');
470 return;
471 }
472
473 const fields = [
474 { name: 'name', type: 'text', label: 'Feed Name', required: true, value: feed.name },
475 ];
476
477 (schema.fields || []).forEach(f => {
478 fields.push({
479 name: f.key,
480 type: f.fieldType,
481 label: f.label,
482 required: f.required,
483 value: feed.config[f.key] != null ? feed.config[f.key] : (f.default || ''),
484 options: f.options,
485 placeholder: f.placeholder || '',
486 description: f.description,
487 });
488 });
489
490 BB.ui.openFormModal({
491 title: 'Edit ' + source.name,
492 fields,
493 submitLabel: 'Save',
494 onSubmit: async (data) => {
495 const name = data.name;
496 delete data.name;
497 await BB.api.feeds.update(feed.id, name, data);
498 BB.ui.showToast('Feed updated!');
499 load();
500 },
501 });
502 }
503
504 BB.state.subscribe('sources', render);
505
506 BB.sources = { load, render, select, selectTag, editTags, editFeed, deleteFeed };
507 })();
508