/** * @fileoverview Item detail panel: full article view with read/star/open actions. * * Loads a single item by ID, renders it into the right-side detail panel, * and provides action buttons. * * Subscribes to BB.state items changes so that external refreshes (sync, * feed fetch, auto-fetch) update the detail view without re-selection. */ (function() { 'use strict'; const { escapeHtml, sanitizeHtml, getErrorMessage } = BB.utils; /** Update the sidebar reading list count badge. */ function updateSavedBadge() { if (BB.bookmarks) BB.bookmarks.updateBadge(); } /** @type {Object|null} Currently displayed item (mutable local copy). */ let currentItem = null; /** * Fetch a full item and display it in the detail panel. * @param {string} id - Item external_id. */ async function load(id) { // Skip if already showing this item if (currentItem && currentItem.id === id) return; const panel = document.getElementById('detail-panel'); try { const item = await BB.api.items.get(id); currentItem = item; panel.style.display = 'flex'; BB.navigation.openMobileDetail(); renderDetail(item); } catch (err) { BB.ui.showErrorWithRetry('Failed to load item: ' + getErrorMessage(err), () => load(id)); } } /** * Sync currentItem with externally refreshed items list. * * List items (ItemSummaryResponse) lack detail-only fields (body, media, * tags, fetched_at), so we merge updated summary fields into the existing * currentItem rather than replacing it. * * @param {Array} items - New items array from state. */ function onItemsChanged(items) { if (!currentItem) return; const updated = items.find(i => i.id === currentItem.id); if (!updated) { // Item no longer in the filtered list (e.g. marked read while // viewing unread-only). Keep the detail panel open — only close // when the user explicitly navigates away. return; } // Merge summary-level fields that may have changed externally const summaryFields = [ 'isRead', 'isStarred', 'score', 'text', 'title', 'secondary', 'indicator', 'timeAgo', 'author', 'sourceName', ]; let changed = false; for (const field of summaryFields) { if (updated[field] !== undefined && updated[field] !== currentItem[field]) { currentItem[field] = updated[field]; changed = true; } } if (changed) { renderDetail(currentItem); } } BB.state.subscribe('items', onItemsChanged); /** * Render the full item detail HTML: title, meta, tags, body, action buttons. * @param {Object} item - Full item object from the backend. */ function renderDetail(item) { const detail = document.getElementById('item-detail'); const tags = item.tags && item.tags.length > 0 ? `
${item.tags.map(t => `${escapeHtml(t)}`).join('')}
` : ''; detail.innerHTML = `

${escapeHtml(item.title || item.text)}

${escapeHtml(item.author)} ${escapeHtml(item.sourceName)} ${escapeHtml(item.timeAgo)} ${item.score !== null && item.score !== undefined ? `Score: ${escapeHtml(String(item.score))}` : ''}
${tags}
${sanitizeHtml(item.body || item.text)}
`; const actions = document.createElement('div'); actions.className = 'detail-actions'; const readBtn = document.createElement('button'); readBtn.className = 'btn'; readBtn.textContent = item.isRead ? 'Mark Unread' : 'Mark Read'; readBtn.addEventListener('click', toggleRead); actions.appendChild(readBtn); const starBtn = document.createElement('button'); starBtn.className = 'btn'; starBtn.textContent = item.isStarred ? 'Unstar' : 'Star'; starBtn.addEventListener('click', toggleStar); actions.appendChild(starBtn); if (item.url) { const readerBtn = document.createElement('button'); readerBtn.className = 'btn'; readerBtn.textContent = 'Reader View'; readerBtn.addEventListener('click', readerView); actions.appendChild(readerBtn); const openBtn = document.createElement('button'); openBtn.className = 'btn btn-primary'; openBtn.textContent = 'Open'; openBtn.addEventListener('click', openUrl); actions.appendChild(openBtn); } // Plugin-declared custom action buttons if (item.actions && item.actions.length > 0) { for (const action of item.actions) { const btn = document.createElement('button'); btn.className = 'btn'; btn.textContent = action.label; if (action.actionType === 'open') { btn.addEventListener('click', () => { window.__TAURI__.shell.open(action.url).catch(() => { BB.ui.showToast('Failed to open URL', 'error'); }); }); } else if (action.actionType === 'download') { btn.addEventListener('click', async () => { BB.ui.showToast('Downloading...'); try { await BB.api.actions.downloadAndOpen(action.url); } catch (err) { BB.ui.showToast('Download failed: ' + getErrorMessage(err), 'error'); } }); } actions.appendChild(btn); } } detail.appendChild(actions); } /** Close the detail panel and deselect the current item. */ function close() { collapseReader(); BB.navigation.closeMobileDetail(); document.getElementById('detail-panel').style.display = 'none'; BB.state.set('selectedItemId', null); currentItem = null; BB.items.render(BB.state.items); } /** Expand the detail panel over the items list with reader view content. */ function expandReader() { document.querySelector('.main').classList.add('reader-expanded'); // Add back button to detail header if not already present const header = document.querySelector('.detail-header'); if (!header.querySelector('.reader-back-btn')) { const backBtn = document.createElement('button'); backBtn.className = 'btn btn-small reader-back-btn'; backBtn.textContent = '\u2190 Back'; backBtn.addEventListener('click', collapseReader); header.prepend(backBtn); } // Trigger reader view extraction readerView(); } /** Collapse expanded reader view back to normal layout. */ function collapseReader() { document.querySelector('.main').classList.remove('reader-expanded'); const backBtn = document.querySelector('.reader-back-btn'); if (backBtn) backBtn.remove(); } /** Toggle read on the current item. Re-fetches from backend after mutation. */ async function toggleRead() { if (!currentItem) return; try { if (currentItem.isRead) { await BB.api.items.markUnread(currentItem.id); } else { await BB.api.items.markRead(currentItem.id); } await BB.items.reload(); } catch (err) { BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); } } /** Toggle star on the current item. Re-fetches from backend after mutation. */ async function toggleStar() { if (!currentItem) return; try { if (currentItem.isStarred) { await BB.api.items.unstar(currentItem.id); } else { await BB.api.items.star(currentItem.id); } await BB.items.reload(); } catch (err) { BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); } } /** * Open the current item's URL in the system browser via Tauri shell API. * No-op if no item is selected or the item has no URL. */ function openUrl() { if (currentItem && currentItem.url) { window.__TAURI__.shell.open(currentItem.url).catch(() => { BB.ui.showToast('Failed to open URL', 'error'); }); } } /** * Fetch and display the reader view (cleaned article content) for the current item. * @returns {Promise} */ async function readerView() { if (!currentItem || !currentItem.url) return; try { BB.ui.showToast('Extracting article...'); const result = await BB.api.reader.extract(currentItem.url); currentItem.body = result.content; if (result.title) currentItem.title = result.title; renderDetail(currentItem); } catch (err) { BB.ui.showToast('Reader view failed: ' + getErrorMessage(err), 'error'); } } /** * Bookmark the current feed item (add to Reading List). */ function bookmarkItem() { if (BB.bookmarks) BB.bookmarks.bookmarkCurrentItem(); } /** * Get the currently displayed item (for bookmarks module). * @returns {Object|null} */ function getCurrentItem() { return currentItem; } BB.detail = { load, close, toggleRead, toggleStar, openUrl, readerView, expandReader, collapseReader, bookmarkItem, getCurrentItem, updateSavedBadge }; })();