Skip to main content

max / balanced_breakfast

10.0 KB · 275 lines History Blame Raw
1 /**
2 * @fileoverview Item detail panel: full article view with read/star/open actions.
3 *
4 * Loads a single item by ID, renders it into the right-side detail panel,
5 * and provides action buttons.
6 *
7 * Subscribes to BB.state items changes so that external refreshes (sync,
8 * feed fetch, auto-fetch) update the detail view without re-selection.
9 */
10 (function() {
11 'use strict';
12
13 const { escapeHtml, sanitizeHtml, getErrorMessage } = BB.utils;
14
15 /** Update the sidebar reading list count badge. */
16 function updateSavedBadge() {
17 if (BB.bookmarks) BB.bookmarks.updateBadge();
18 }
19
20 /** @type {Object|null} Currently displayed item (mutable local copy). */
21 let currentItem = null;
22
23 /**
24 * Fetch a full item and display it in the detail panel.
25 * @param {string} id - Item external_id.
26 */
27 async function load(id) {
28 // Skip if already showing this item
29 if (currentItem && currentItem.id === id) return;
30
31 const panel = document.getElementById('detail-panel');
32
33 try {
34 const item = await BB.api.items.get(id);
35 currentItem = item;
36 panel.style.display = 'flex';
37 BB.navigation.openMobileDetail();
38 renderDetail(item);
39 } catch (err) {
40 BB.ui.showErrorWithRetry('Failed to load item: ' + getErrorMessage(err), () => load(id));
41 }
42 }
43
44 /**
45 * Sync currentItem with externally refreshed items list.
46 *
47 * List items (ItemSummaryResponse) lack detail-only fields (body, media,
48 * tags, fetched_at), so we merge updated summary fields into the existing
49 * currentItem rather than replacing it.
50 *
51 * @param {Array<Object>} items - New items array from state.
52 */
53 function onItemsChanged(items) {
54 if (!currentItem) return;
55
56 const updated = items.find(i => i.id === currentItem.id);
57 if (!updated) {
58 // Item no longer in the filtered list (e.g. marked read while
59 // viewing unread-only). Keep the detail panel open — only close
60 // when the user explicitly navigates away.
61 return;
62 }
63
64 // Merge summary-level fields that may have changed externally
65 const summaryFields = [
66 'isRead', 'isStarred', 'score', 'text', 'title',
67 'secondary', 'indicator', 'timeAgo', 'author', 'sourceName',
68 ];
69 let changed = false;
70 for (const field of summaryFields) {
71 if (updated[field] !== undefined && updated[field] !== currentItem[field]) {
72 currentItem[field] = updated[field];
73 changed = true;
74 }
75 }
76
77 if (changed) {
78 renderDetail(currentItem);
79 }
80 }
81
82 BB.state.subscribe('items', onItemsChanged);
83
84 /**
85 * Render the full item detail HTML: title, meta, tags, body, action buttons.
86 * @param {Object} item - Full item object from the backend.
87 */
88 function renderDetail(item) {
89 const detail = document.getElementById('item-detail');
90 const tags = item.tags && item.tags.length > 0
91 ? `<div class="detail-tags">${item.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
92 : '';
93
94 detail.innerHTML = `
95 <h2 class="detail-title">${escapeHtml(item.title || item.text)}</h2>
96 <div class="detail-meta">
97 <span>${escapeHtml(item.author)}</span>
98 <span>${escapeHtml(item.sourceName)}</span>
99 <span>${escapeHtml(item.timeAgo)}</span>
100 ${item.score !== null && item.score !== undefined ? `<span>Score: ${escapeHtml(String(item.score))}</span>` : ''}
101 </div>
102 ${tags}
103 <div class="detail-body">${sanitizeHtml(item.body || item.text)}</div>
104 `;
105
106 const actions = document.createElement('div');
107 actions.className = 'detail-actions';
108
109 const readBtn = document.createElement('button');
110 readBtn.className = 'btn';
111 readBtn.textContent = item.isRead ? 'Mark Unread' : 'Mark Read';
112 readBtn.addEventListener('click', toggleRead);
113 actions.appendChild(readBtn);
114
115 const starBtn = document.createElement('button');
116 starBtn.className = 'btn';
117 starBtn.textContent = item.isStarred ? 'Unstar' : 'Star';
118 starBtn.addEventListener('click', toggleStar);
119 actions.appendChild(starBtn);
120
121 if (item.url) {
122 const readerBtn = document.createElement('button');
123 readerBtn.className = 'btn';
124 readerBtn.textContent = 'Reader View';
125 readerBtn.addEventListener('click', readerView);
126 actions.appendChild(readerBtn);
127
128 const openBtn = document.createElement('button');
129 openBtn.className = 'btn btn-primary';
130 openBtn.textContent = 'Open';
131 openBtn.addEventListener('click', openUrl);
132 actions.appendChild(openBtn);
133 }
134
135 // Plugin-declared custom action buttons
136 if (item.actions && item.actions.length > 0) {
137 for (const action of item.actions) {
138 const btn = document.createElement('button');
139 btn.className = 'btn';
140 btn.textContent = action.label;
141 if (action.actionType === 'open') {
142 btn.addEventListener('click', () => {
143 window.__TAURI__.shell.open(action.url).catch(() => {
144 BB.ui.showToast('Failed to open URL', 'error');
145 });
146 });
147 } else if (action.actionType === 'download') {
148 btn.addEventListener('click', async () => {
149 BB.ui.showToast('Downloading...');
150 try {
151 await BB.api.actions.downloadAndOpen(action.url);
152 } catch (err) {
153 BB.ui.showToast('Download failed: ' + getErrorMessage(err), 'error');
154 }
155 });
156 }
157 actions.appendChild(btn);
158 }
159 }
160
161 detail.appendChild(actions);
162 }
163
164 /** Close the detail panel and deselect the current item. */
165 function close() {
166 collapseReader();
167 BB.navigation.closeMobileDetail();
168 document.getElementById('detail-panel').style.display = 'none';
169 BB.state.set('selectedItemId', null);
170 currentItem = null;
171 BB.items.render(BB.state.items);
172 }
173
174 /** Expand the detail panel over the items list with reader view content. */
175 function expandReader() {
176 document.querySelector('.main').classList.add('reader-expanded');
177
178 // Add back button to detail header if not already present
179 const header = document.querySelector('.detail-header');
180 if (!header.querySelector('.reader-back-btn')) {
181 const backBtn = document.createElement('button');
182 backBtn.className = 'btn btn-small reader-back-btn';
183 backBtn.textContent = '\u2190 Back';
184 backBtn.addEventListener('click', collapseReader);
185 header.prepend(backBtn);
186 }
187
188 // Trigger reader view extraction
189 readerView();
190 }
191
192 /** Collapse expanded reader view back to normal layout. */
193 function collapseReader() {
194 document.querySelector('.main').classList.remove('reader-expanded');
195 const backBtn = document.querySelector('.reader-back-btn');
196 if (backBtn) backBtn.remove();
197 }
198
199 /** Toggle read on the current item. Re-fetches from backend after mutation. */
200 async function toggleRead() {
201 if (!currentItem) return;
202 try {
203 if (currentItem.isRead) {
204 await BB.api.items.markUnread(currentItem.id);
205 } else {
206 await BB.api.items.markRead(currentItem.id);
207 }
208 await BB.items.reload();
209 } catch (err) {
210 BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
211 }
212 }
213
214 /** Toggle star on the current item. Re-fetches from backend after mutation. */
215 async function toggleStar() {
216 if (!currentItem) return;
217 try {
218 if (currentItem.isStarred) {
219 await BB.api.items.unstar(currentItem.id);
220 } else {
221 await BB.api.items.star(currentItem.id);
222 }
223 await BB.items.reload();
224 } catch (err) {
225 BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
226 }
227 }
228
229 /**
230 * Open the current item's URL in the system browser via Tauri shell API.
231 * No-op if no item is selected or the item has no URL.
232 */
233 function openUrl() {
234 if (currentItem && currentItem.url) {
235 window.__TAURI__.shell.open(currentItem.url).catch(() => {
236 BB.ui.showToast('Failed to open URL', 'error');
237 });
238 }
239 }
240
241 /**
242 * Fetch and display the reader view (cleaned article content) for the current item.
243 * @returns {Promise<void>}
244 */
245 async function readerView() {
246 if (!currentItem || !currentItem.url) return;
247 try {
248 BB.ui.showToast('Extracting article...');
249 const result = await BB.api.reader.extract(currentItem.url);
250 currentItem.body = result.content;
251 if (result.title) currentItem.title = result.title;
252 renderDetail(currentItem);
253 } catch (err) {
254 BB.ui.showToast('Reader view failed: ' + getErrorMessage(err), 'error');
255 }
256 }
257
258 /**
259 * Bookmark the current feed item (add to Reading List).
260 */
261 function bookmarkItem() {
262 if (BB.bookmarks) BB.bookmarks.bookmarkCurrentItem();
263 }
264
265 /**
266 * Get the currently displayed item (for bookmarks module).
267 * @returns {Object|null}
268 */
269 function getCurrentItem() {
270 return currentItem;
271 }
272
273 BB.detail = { load, close, toggleRead, toggleStar, openUrl, readerView, expandReader, collapseReader, bookmarkItem, getCurrentItem, updateSavedBadge };
274 })();
275