/** * @fileoverview Sources sidebar: lists feed sources with unread counts. * * Each source is a busser_id. Selecting a source filters the items list. * The "All" entry (empty source) shows all items across all sources. */ (function() { 'use strict'; const { escapeHtml, getErrorMessage } = BB.utils; /** * Fetch the source list, all tags, and query feeds from the backend. * @returns {Promise} */ async function load() { try { const [sources, allTags] = await Promise.all([ BB.api.sources.list(), BB.api.feeds.listAllTags(), ]); BB.state.set('allTags', allTags); BB.state.set('sources', sources); // Load query feeds in parallel (non-blocking). BB.queryFeeds.load(); } catch (err) { BB.ui.showErrorWithRetry('Failed to load sources: ' + getErrorMessage(err), load); } } /** * Render the sources sidebar. Shows "All" with aggregate counts, then * individual sources with per-source unread/total counts. * @param {Array} sources - Source objects with id, name, totalCount, unreadCount. */ function render(sources) { const list = document.getElementById('sources-list'); const current = BB.state.currentSource; // Set listbox role on the container for accessibility list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Feed sources'); // Render tag filter bar above the sources list renderTagBar(); // Total counts const totalCount = sources.reduce((sum, s) => sum + s.totalCount, 0); const totalUnread = sources.reduce((sum, s) => sum + s.unreadCount, 0); const allReadClass = totalUnread === 0 && totalCount > 0 ? ' all-read' : ''; const allCountText = totalUnread > 0 ? `${totalUnread}/${totalCount}` : totalCount > 0 ? `\u2713 ${totalCount}` : `${totalCount}`; // Build all items in a fragment first, then swap in one operation // to avoid the flash of empty content. const frag = document.createDocumentFragment(); const allLi = document.createElement('li'); allLi.className = 'source-item' + (current === '' ? ' active' : ''); allLi.dataset.source = ''; allLi.setAttribute('role', 'option'); allLi.setAttribute('aria-selected', current === '' ? 'true' : 'false'); allLi.setAttribute('tabindex', '0'); allLi.innerHTML = 'All' + allCountText + ''; allLi.addEventListener('click', () => BB.sources.select('')); allLi.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); BB.sources.select(''); } }); frag.appendChild(allLi); // Empty-state onboarding when no feeds exist. // F4 (2026-06-02): inline styles + broken `var(--yolk)` token // retired in favor of .source-item--empty + .source-item-empty-* // classes. if (sources.length === 0) { const emptyLi = document.createElement('li'); emptyLi.className = 'source-item source-item--empty'; emptyLi.innerHTML = '
\uD83C\uDF73
Add your first feed to get started.
Click + Add Feed above,
or import an OPML file.'; frag.appendChild(emptyLi); } sources.forEach(source => { const li = document.createElement('li'); li.className = 'source-item' + (current === source.id ? ' active' : ''); li.dataset.source = source.id; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', current === source.id ? 'true' : 'false'); li.setAttribute('tabindex', '0'); li.onclick = () => BB.sources.select(source.id); li.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); BB.sources.select(source.id); } }; const srcReadClass = source.unreadCount === 0 && source.totalCount > 0 ? ' all-read' : ''; const countText = source.unreadCount > 0 ? `${source.unreadCount}/${source.totalCount}` : source.totalCount > 0 ? `\u2713 ${source.totalCount}` : `${source.totalCount}`; const healthLabels = { yellow: 'Feed has warnings', red: 'Feed has errors', circuit_broken: 'Feed disabled by circuit breaker', auth_error: 'Feed authentication failed', config_error: 'Feed configuration error', rate_limited: 'Feed rate-limited; will retry', }; const healthDot = source.health && source.health !== 'green' // F3: state-by-color paired with shape via [data-health] CSS // rules + aria-label for screen readers. F5: dropped the // legacy `health-${health}` class (now handled by [data-health]). ? `` : ''; const tagChips = (source.tags || []) .map(t => `${escapeHtml(t)}`) .join(''); li.innerHTML = ` ${healthDot}
${escapeHtml(source.name)} ${tagChips ? `
${tagChips}
` : ''}
${countText} `; li.querySelector('.source-edit').onclick = (e) => { e.stopPropagation(); showFeedPopover(source, e.currentTarget); }; frag.appendChild(li); }); // Render query feeds section const queryFeeds = BB.state.queryFeeds || []; if (queryFeeds.length > 0) { const divider = document.createElement('li'); divider.className = 'source-divider'; divider.textContent = 'Saved Filters'; frag.appendChild(divider); const currentQF = BB.state.currentQueryFeed; queryFeeds.forEach(qf => { const li = document.createElement('li'); li.className = 'source-item query-feed' + (currentQF === qf.id ? ' active' : ''); li.setAttribute('role', 'option'); li.setAttribute('aria-selected', currentQF === qf.id ? 'true' : 'false'); li.setAttribute('tabindex', '0'); li.onclick = () => BB.queryFeeds.select(qf.id); li.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); BB.queryFeeds.select(qf.id); } }; li.innerHTML = `
${escapeHtml(qf.name)}
${escapeHtml(String(qf.matchCount))} `; li.querySelector('.source-edit').onclick = (e) => { e.stopPropagation(); BB.queryFeeds.openBuilder(qf); }; li.querySelector('.source-delete').onclick = (e) => { e.stopPropagation(); BB.queryFeeds.deleteFeed(qf); }; frag.appendChild(li); }); } // "+ Saved Filter" button const addQfBtn = document.createElement('li'); addQfBtn.className = 'source-item add-query-feed-btn'; addQfBtn.innerHTML = '+ Saved Filter'; addQfBtn.onclick = () => BB.queryFeeds.openBuilder(null); frag.appendChild(addQfBtn); // Single DOM operation: clear and append list.innerHTML = ''; list.appendChild(frag); // Update pinned reading list entry const savedBtn = document.getElementById('saved-articles-btn'); if (savedBtn) { savedBtn.classList.toggle('active', current === '__saved__'); if (BB.bookmarks) BB.bookmarks.updateBadge(); } } /** * Show a floating popover with health info and feed actions. * @param {Object} source - Source object from the backend. * @param {HTMLElement} anchor - The button element to anchor against. */ function showFeedPopover(source, anchor) { // Remove any existing popover const old = document.getElementById('feed-popover'); if (old) old.remove(); const pop = document.createElement('div'); pop.id = 'feed-popover'; pop.className = 'health-popover'; // Health section const statusMap = { green: { label: 'Healthy', cls: 'hp-green' }, yellow: { label: 'Intermittent errors', cls: 'hp-yellow' }, red: { label: 'Failing', cls: 'hp-red' }, rate_limited: { label: 'Rate limited', cls: 'hp-yellow' }, auth_error: { label: 'Auth error', cls: 'hp-red' }, config_error: { label: 'Config error', cls: 'hp-red' }, circuit_broken: { label: 'Circuit broken', cls: 'hp-grey' }, }; const info = statusMap[source.health] || statusMap.green; let rows = `
Status${info.label}
`; if (source.errorCategory && source.errorCategory !== 'unknown') { rows += `
Error type${escapeHtml(source.errorCategory)}
`; } if (source.lastError) { const msg = source.lastError.length > 120 ? source.lastError.slice(0, 120) + '\u2026' : source.lastError; rows += `
Last error${escapeHtml(msg)}
`; } if (source.retryAfterSecs) { rows += `
Retry after${source.retryAfterSecs}s
`; } if (source.circuitBroken) { rows += `
Auto-fetchDisabled
`; } // Action buttons const actions = `
`; pop.innerHTML = `
${escapeHtml(source.name)}
${rows}${actions}`; document.body.appendChild(pop); // Position relative to the anchor button const rect = anchor.getBoundingClientRect(); pop.style.top = (rect.bottom + 4) + 'px'; pop.style.left = Math.max(4, rect.left - 100) + 'px'; // Wire up action buttons function dismiss() { pop.remove(); document.removeEventListener('click', outsideClick, true); } pop.querySelector('[data-action="mark-read"]').onclick = async () => { dismiss(); try { await BB.api.items.markAllRead(source.id); BB.ui.showToast('Marked all read in ' + source.name); await BB.sources.load(); await BB.items.load(); } catch (err) { BB.ui.showToast('Failed: ' + getErrorMessage(err), 'error'); } }; pop.querySelector('[data-action="edit"]').onclick = () => { dismiss(); editFeed(source); }; pop.querySelector('[data-action="tags"]').onclick = () => { dismiss(); editTags(source); }; pop.querySelector('[data-action="delete"]').onclick = () => { dismiss(); deleteFeed(source); }; // Close on outside click function outsideClick(e) { if (!pop.contains(e.target) && e.target !== anchor) { dismiss(); } } requestAnimationFrame(() => { document.addEventListener('click', outsideClick, true); }); } /** * Select a source: reset pagination, clear item selection, reload items, * then update sidebar to reflect new active state. * @param {string} sourceId - Busser ID to filter by, or '' for all. */ async function select(sourceId) { BB.detail.collapseReader(); BB.state.set('currentQueryFeed', null); BB.state.set('currentSource', sourceId); // Clear search when switching sources so results aren't unexpectedly filtered BB.state.set('currentSearch', ''); const searchInput = document.getElementById('search-input'); if (searchInput) searchInput.value = ''; const mobileSearch = document.getElementById('mobile-search-input'); if (mobileSearch) mobileSearch.value = ''; BB.state.resetPagination(true); await BB.items.load(); render(BB.state.sources); BB.navigation.onSourceSelected(sourceId); } /** * Delete a source and all its items after user confirmation. * Shows an undo toast that can re-create the feed with the same config. * @param {Object} source - Source object with `id` and `name`. */ async function deleteFeed(source) { const ok = await BB.ui.confirmAction('Delete "' + source.name + '" and all its items?'); if (!ok) return; // Snapshot the full feed details (including config) before deleting // so we can faithfully restore them on undo. let feedSnapshots = null; try { feedSnapshots = await BB.api.feeds.getByBusser(source.id); } catch (_) { // If snapshot fails, proceed without undo capability } try { await BB.api.feeds.deleteByBusser(source.id); // Show undo toast if we have snapshot data if (feedSnapshots && feedSnapshots.length > 0) { BB.ui.showToast('Deleted ' + source.name, 'success', { action: { label: 'Undo', fn: async () => { try { for (const snap of feedSnapshots) { await BB.api.feeds.create({ busserId: snap.busserId, name: snap.name, config: snap.config, }); } BB.ui.showToast('Restored ' + source.name); load(); BB.items.load(); } catch (err) { BB.ui.showToast('Failed to restore feed: ' + getErrorMessage(err), 'error'); } }, }, duration: 6000, }); } else { BB.ui.showToast('Deleted ' + source.name); } select(''); load(); BB.items.load(); } catch (err) { BB.ui.showToast('Failed to delete feed: ' + getErrorMessage(err), 'error'); } } /** Render the horizontal tag filter bar above the sources list. */ function renderTagBar() { const allTags = BB.state.allTags || []; let bar = document.getElementById('tag-filter-bar'); if (!bar) { bar = document.createElement('div'); bar.id = 'tag-filter-bar'; bar.className = 'tag-filter-bar'; const list = document.getElementById('sources-list'); list.parentNode.insertBefore(bar, list); } if (allTags.length === 0) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; const currentTag = BB.state.currentTag || ''; bar.innerHTML = ''; const allBtn = document.createElement('button'); allBtn.className = 'tag-chip' + (currentTag === '' ? ' active' : ''); allBtn.textContent = 'All'; allBtn.addEventListener('click', () => selectTag('')); bar.appendChild(allBtn); allTags.forEach(t => { const btn = document.createElement('button'); btn.className = 'tag-chip' + (currentTag === t ? ' active' : ''); btn.textContent = t; btn.addEventListener('click', () => selectTag(t)); bar.appendChild(btn); }); } /** * Select a tag filter: reset pagination and reload items. * @param {string} tag - Tag name to filter by, or '' for all. */ function selectTag(tag) { BB.state.set('currentTag', tag); BB.state.resetPagination(true); renderTagBar(); BB.items.load(); } /** * Open a form modal to edit tags on a source (comma-separated). * @param {Object} source - Source object with `id` and `name`. */ function editTags(source) { const currentTags = (source.tags || []).join(', '); BB.ui.openFormModal({ title: 'Edit Tags \u2014 ' + source.name, fields: [ { name: 'tags', type: 'text', label: 'Tags (comma-separated)', value: currentTags, placeholder: 'news, tech, daily', }, ], submitLabel: 'Save Tags', onSubmit: async (data) => { const tags = data.tags.split(',').map(t => t.trim()).filter(t => t.length > 0); await BB.api.feeds.setTags(source.id, tags); BB.ui.showToast('Tags updated'); load(); }, }); } /** * Open a form modal to edit a feed's name and config fields. * @param {Object} source - Source object with `id` and `name`. */ async function editFeed(source) { let feed, schema; try { [feed, schema] = await Promise.all([ BB.api.feeds.get(source.id), BB.api.plugins.schema(source.id), ]); } catch (err) { BB.ui.showToast('Failed to load feed details: ' + getErrorMessage(err), 'error'); return; } const fields = [ { name: 'name', type: 'text', label: 'Feed Name', required: true, value: feed.name }, ]; (schema.fields || []).forEach(f => { fields.push({ name: f.key, type: f.fieldType, label: f.label, required: f.required, value: feed.config[f.key] != null ? feed.config[f.key] : (f.default || ''), options: f.options, placeholder: f.placeholder || '', description: f.description, }); }); BB.ui.openFormModal({ title: 'Edit ' + source.name, fields, submitLabel: 'Save', onSubmit: async (data) => { const name = data.name; delete data.name; await BB.api.feeds.update(feed.id, name, data); BB.ui.showToast('Feed updated!'); load(); }, }); } BB.state.subscribe('sources', render); BB.sources = { load, render, select, selectTag, editTags, editFeed, deleteFeed }; })();