| 9 |
9 |
|
|
| 10 |
10 |
|
const { escapeHtml, getErrorMessage } = BB.utils;
|
| 11 |
11 |
|
|
|
12 |
+ |
/** Human-readable health status labels (aria-label / tooltip). */
|
|
13 |
+ |
const healthLabels = {
|
|
14 |
+ |
yellow: 'Feed has warnings',
|
|
15 |
+ |
red: 'Feed has errors',
|
|
16 |
+ |
circuit_broken: 'Feed disabled by circuit breaker',
|
|
17 |
+ |
auth_error: 'Feed authentication failed',
|
|
18 |
+ |
config_error: 'Feed configuration error',
|
|
19 |
+ |
rate_limited: 'Feed rate-limited; will retry',
|
|
20 |
+ |
};
|
|
21 |
+ |
|
|
22 |
+ |
// ── Row-part builders (fed into BB.ui.renderRow slots) ──
|
|
23 |
+ |
// Building elements (not innerHTML templates) keeps rows XSS-safe by
|
|
24 |
+ |
// construction and routes every sidebar row through the shared primitive.
|
|
25 |
+ |
|
|
26 |
+ |
/** Source/query name as a `.source-name` span (primary slot). */
|
|
27 |
+ |
function nameEl(name) {
|
|
28 |
+ |
const el = document.createElement('span');
|
|
29 |
+ |
el.className = 'source-name';
|
|
30 |
+ |
el.textContent = name;
|
|
31 |
+ |
return el;
|
|
32 |
+ |
}
|
|
33 |
+ |
|
|
34 |
+ |
/** Unread/total count pill as a `.source-count` span. */
|
|
35 |
+ |
function countEl(text, allRead) {
|
|
36 |
+ |
const el = document.createElement('span');
|
|
37 |
+ |
el.className = 'source-count' + (allRead ? ' all-read' : '');
|
|
38 |
+ |
el.textContent = text;
|
|
39 |
+ |
return el;
|
|
40 |
+ |
}
|
|
41 |
+ |
|
|
42 |
+ |
/** A `.source-edit`/`.source-delete` icon button with a static glyph. */
|
|
43 |
+ |
function iconBtn(cls, glyph, title, onClick) {
|
|
44 |
+ |
const b = document.createElement('button');
|
|
45 |
+ |
b.className = cls;
|
|
46 |
+ |
b.title = title;
|
|
47 |
+ |
b.setAttribute('aria-label', title);
|
|
48 |
+ |
b.innerHTML = glyph; // static HTML entity (gear / times) — safe
|
|
49 |
+ |
b.onclick = onClick;
|
|
50 |
+ |
return b;
|
|
51 |
+ |
}
|
|
52 |
+ |
|
|
53 |
+ |
/** Wrap trailing controls in a `.source-actions` cluster (meta slot). */
|
|
54 |
+ |
function actionsEl(children) {
|
|
55 |
+ |
const wrap = document.createElement('span');
|
|
56 |
+ |
wrap.className = 'source-actions';
|
|
57 |
+ |
children.forEach(c => c && wrap.appendChild(c));
|
|
58 |
+ |
return wrap;
|
|
59 |
+ |
}
|
|
60 |
+ |
|
|
61 |
+ |
/** Leading `.health-dot` for a non-green feed, or null. */
|
|
62 |
+ |
function healthDotEl(source) {
|
|
63 |
+ |
if (!source.health || source.health === 'green') return null;
|
|
64 |
+ |
const dot = document.createElement('span');
|
|
65 |
+ |
dot.className = 'health-dot';
|
|
66 |
+ |
dot.setAttribute('data-health', source.health);
|
|
67 |
+ |
// F3: state-by-color paired with shape via [data-health] CSS + aria-label.
|
|
68 |
+ |
dot.setAttribute('aria-label', source.lastError || healthLabels[source.health] || 'Feed status');
|
|
69 |
+ |
dot.title = source.lastError || healthLabels[source.health] || 'Feed has errors';
|
|
70 |
+ |
return dot;
|
|
71 |
+ |
}
|
|
72 |
+ |
|
|
73 |
+ |
/** Tag chips as a `.source-tags` block (secondary slot), or null. */
|
|
74 |
+ |
function tagsEl(tags) {
|
|
75 |
+ |
if (!tags || tags.length === 0) return null;
|
|
76 |
+ |
const wrap = document.createElement('div');
|
|
77 |
+ |
wrap.className = 'source-tags';
|
|
78 |
+ |
tags.forEach(t => {
|
|
79 |
+ |
const chip = document.createElement('span');
|
|
80 |
+ |
chip.className = 'source-tag-chip';
|
|
81 |
+ |
chip.textContent = t;
|
|
82 |
+ |
wrap.appendChild(chip);
|
|
83 |
+ |
});
|
|
84 |
+ |
return wrap;
|
|
85 |
+ |
}
|
|
86 |
+ |
|
|
87 |
+ |
/** Attach Enter/Space activation to a rendered row. */
|
|
88 |
+ |
function wireRowKeys(li, activate) {
|
|
89 |
+ |
li.onkeydown = (e) => {
|
|
90 |
+ |
if (e.key === 'Enter' || e.key === ' ') {
|
|
91 |
+ |
e.preventDefault();
|
|
92 |
+ |
activate();
|
|
93 |
+ |
}
|
|
94 |
+ |
};
|
|
95 |
+ |
}
|
|
96 |
+ |
|
| 12 |
97 |
|
/**
|
| 13 |
98 |
|
* Fetch the source list, all tags, and query feeds from the backend.
|
| 14 |
99 |
|
* @returns {Promise<void>}
|
| 56 |
141 |
|
// to avoid the flash of empty content.
|
| 57 |
142 |
|
const frag = document.createDocumentFragment();
|
| 58 |
143 |
|
|
| 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 |
|
- |
}
|
|
144 |
+ |
const allLi = BB.ui.renderRow({
|
|
145 |
+ |
tag: 'li',
|
|
146 |
+ |
className: 'source-item' + (current === '' ? ' active' : ''),
|
|
147 |
+ |
primary: nameEl('All'),
|
|
148 |
+ |
meta: countEl(allCountText, allReadClass !== ''),
|
|
149 |
+ |
attrs: {
|
|
150 |
+ |
role: 'option',
|
|
151 |
+ |
'aria-selected': current === '' ? 'true' : 'false',
|
|
152 |
+ |
tabindex: '0',
|
|
153 |
+ |
'data-source': '',
|
|
154 |
+ |
},
|
|
155 |
+ |
onClick: () => BB.sources.select(''),
|
| 72 |
156 |
|
});
|
|
157 |
+ |
wireRowKeys(allLi, () => BB.sources.select(''));
|
| 73 |
158 |
|
frag.appendChild(allLi);
|
| 74 |
159 |
|
|
| 75 |
160 |
|
// Empty-state onboarding when no feeds exist.
|
| 84 |
169 |
|
}
|
| 85 |
170 |
|
|
| 86 |
171 |
|
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' : '';
|
|
172 |
+ |
const activate = () => BB.sources.select(source.id);
|
|
173 |
+ |
const srcReadClass = source.unreadCount === 0 && source.totalCount > 0;
|
| 102 |
174 |
|
const countText = source.unreadCount > 0
|
| 103 |
175 |
|
? `${source.unreadCount}/${source.totalCount}`
|
| 104 |
176 |
|
: source.totalCount > 0 ? `\u2713 ${source.totalCount}` : `${source.totalCount}`;
|
| 105 |
177 |
|
|
| 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">⚙</button>
|
| 134 |
|
- |
</span>
|
| 135 |
|
- |
`;
|
| 136 |
|
- |
|
| 137 |
|
- |
li.querySelector('.source-edit').onclick = (e) => {
|
| 138 |
|
- |
e.stopPropagation();
|
| 139 |
|
- |
showFeedPopover(source, e.currentTarget);
|
| 140 |
|
- |
};
|
| 141 |
|
- |
|
|
178 |
+ |
const li = BB.ui.renderRow({
|
|
179 |
+ |
tag: 'li',
|
|
180 |
+ |
className: 'source-item' + (current === source.id ? ' active' : ''),
|
|
181 |
+ |
icon: healthDotEl(source),
|
|
182 |
+ |
primary: nameEl(source.name),
|
|
183 |
+ |
secondary: tagsEl(source.tags),
|
|
184 |
+ |
meta: actionsEl([
|
|
185 |
+ |
countEl(countText, srcReadClass),
|
|
186 |
+ |
iconBtn('source-edit', '⚙', 'Feed settings', (e) => {
|
|
187 |
+ |
e.stopPropagation();
|
|
188 |
+ |
showFeedPopover(source, e.currentTarget);
|
|
189 |
+ |
}),
|
|
190 |
+ |
]),
|
|
191 |
+ |
attrs: {
|
|
192 |
+ |
role: 'option',
|
|
193 |
+ |
'aria-selected': current === source.id ? 'true' : 'false',
|
|
194 |
+ |
tabindex: '0',
|
|
195 |
+ |
'data-source': source.id,
|
|
196 |
+ |
},
|
|
197 |
+ |
onClick: activate,
|
|
198 |
+ |
});
|
|
199 |
+ |
wireRowKeys(li, activate);
|
| 142 |
200 |
|
frag.appendChild(li);
|
| 143 |
201 |
|
});
|
| 144 |
202 |
|
|
| 152 |
210 |
|
|
| 153 |
211 |
|
const currentQF = BB.state.currentQueryFeed;
|
| 154 |
212 |
|
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">⚙</button>
|
| 175 |
|
- |
<button class="source-delete" title="Delete query feed" aria-label="Delete query feed">×</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 |
|
- |
|
|
213 |
+ |
const activate = () => BB.queryFeeds.select(qf.id);
|
|
214 |
+ |
const li = BB.ui.renderRow({
|
|
215 |
+ |
tag: 'li',
|
|
216 |
+ |
className: 'source-item query-feed' + (currentQF === qf.id ? ' active' : ''),
|
|
217 |
+ |
primary: nameEl(qf.name),
|
|
218 |
+ |
meta: actionsEl([
|
|
219 |
+ |
countEl(String(qf.matchCount), false),
|
|
220 |
+ |
iconBtn('source-edit', '⚙', 'Edit query feed', (e) => {
|
|
221 |
+ |
e.stopPropagation();
|
|
222 |
+ |
BB.queryFeeds.openBuilder(qf);
|
|
223 |
+ |
}),
|
|
224 |
+ |
iconBtn('source-delete', '×', 'Delete query feed', (e) => {
|
|
225 |
+ |
e.stopPropagation();
|
|
226 |
+ |
BB.queryFeeds.deleteFeed(qf);
|
|
227 |
+ |
}),
|
|
228 |
+ |
]),
|
|
229 |
+ |
attrs: {
|
|
230 |
+ |
role: 'option',
|
|
231 |
+ |
'aria-selected': currentQF === qf.id ? 'true' : 'false',
|
|
232 |
+ |
tabindex: '0',
|
|
233 |
+ |
},
|
|
234 |
+ |
onClick: activate,
|
|
235 |
+ |
});
|
|
236 |
+ |
wireRowKeys(li, activate);
|
| 189 |
237 |
|
frag.appendChild(li);
|
| 190 |
238 |
|
});
|
| 191 |
239 |
|
}
|