Skip to main content

max / balanced_breakfast

18.9 KB · 490 lines History Blame Raw
1 /**
2 * @fileoverview Reading List (bookmarks) module.
3 *
4 * Manages the reading list view: loading, rendering, adding, editing,
5 * and deleting bookmarks. Also handles migration from localStorage saved items.
6 */
7 (function() {
8 'use strict';
9
10 const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils;
11
12 /** Currently selected bookmark tag filter. */
13 let currentTag = null;
14
15 /**
16 * Load bookmarks from the backend and render them.
17 * @param {string} [tag] - Optional tag to filter by.
18 */
19 async function load(tag) {
20 currentTag = tag || null;
21 try {
22 const bookmarks = await BB.api.bookmarks.list(currentTag);
23 renderBookmarks(bookmarks);
24 } catch (err) {
25 BB.ui.showToast('Failed to load reading list: ' + getErrorMessage(err), 'error');
26 }
27 }
28
29 /**
30 * Render the bookmark list into the items panel.
31 * @param {Array<Object>} bookmarks - Bookmark objects from the backend.
32 */
33 function renderBookmarks(bookmarks) {
34 const list = document.getElementById('items-list');
35 list.setAttribute('role', 'listbox');
36 list.setAttribute('aria-label', 'Reading list');
37
38 // Hide the "Load More" button since bookmarks aren't paginated
39 document.getElementById('load-more').style.display = 'none';
40
41 if (bookmarks.length === 0) {
42 const msg = currentTag
43 ? 'No bookmarks with tag "' + escapeHtml(currentTag) + '".'
44 : 'Your reading list is empty.<br>Bookmark articles from the detail panel, or add a URL.';
45 list.innerHTML = '<li class="item empty-state"><div class="empty-icon">&#x1F516;</div>' + msg + '</li>';
46 renderTagBar([]);
47 return;
48 }
49
50 // Render tag filter bar
51 BB.api.bookmarks.listTags().then(renderTagBar).catch(() => {});
52
53 const frag = document.createDocumentFragment();
54
55 for (const bm of bookmarks) {
56 const li = document.createElement('li');
57 li.className = 'item bookmark-item' + (bm.isPinned ? ' pinned' : '');
58 li.dataset.id = bm.id;
59 li.setAttribute('role', 'option');
60 li.setAttribute('tabindex', '0');
61
62 const tags = bm.tags.length > 0
63 ? '<div class="bookmark-tags">' + bm.tags.map(t =>
64 '<span class="tag bookmark-tag" onclick="event.stopPropagation(); BB.bookmarks.load(\'' + escapeAttr(t) + '\')">'
65 + escapeHtml(t) + '</span>'
66 ).join('') + '</div>'
67 : '';
68
69 li.innerHTML = `
70 <div class="item-indicators">
71 <button class="star-btn${bm.isPinned ? ' starred' : ''}"
72 aria-label="Toggle pin"
73 onclick="event.stopPropagation(); BB.bookmarks.togglePin('${escapeAttr(bm.id)}', ${bm.isPinned})">
74 ${bm.isPinned ? '\u{1F4CC}' : '\u{1F4CC}'}
75 </button>
76 </div>
77 <div class="item-content">
78 <div class="item-header">
79 ${bm.author ? '<span class="item-author">' + escapeHtml(bm.author) + '</span>' : ''}
80 <span class="item-time">${escapeHtml(bm.timeAgo)}</span>
81 </div>
82 ${bm.sourceName ? '<span class="item-source-badge">' + escapeHtml(bm.sourceName) + '</span>' : ''}
83 <div class="item-text">${escapeHtml(bm.title || bm.url)}</div>
84 ${tags}
85 </div>
86 <div class="bookmark-actions">
87 <button class="btn btn-small" onclick="event.stopPropagation(); BB.bookmarks.openUrl('${escapeAttr(bm.url)}')" title="Open URL">Open</button>
88 <button class="btn btn-small" onclick="event.stopPropagation(); BB.bookmarks.showContextMenu(event, '${escapeAttr(bm.id)}')" title="More actions">\u22EF</button>
89 </div>
90 `;
91
92 li.onclick = () => openUrl(bm.url);
93 li.onkeydown = (e) => {
94 if (e.key === 'Enter' || e.key === ' ') {
95 e.preventDefault();
96 openUrl(bm.url);
97 }
98 };
99 frag.appendChild(li);
100 }
101
102 list.innerHTML = '';
103 list.appendChild(frag);
104 }
105
106 /**
107 * Render the tag filter bar above the bookmark list.
108 * @param {Array<string>} tags - All distinct bookmark tags.
109 */
110 function renderTagBar(tags) {
111 let bar = document.getElementById('bookmark-tag-bar');
112 if (!bar) {
113 bar = document.createElement('div');
114 bar.id = 'bookmark-tag-bar';
115 bar.className = 'bookmark-tag-bar';
116 const itemsPanel = document.querySelector('.items-panel');
117 const itemsList = document.getElementById('items-list');
118 itemsPanel.insertBefore(bar, itemsList);
119 }
120
121 if (tags.length === 0 && !currentTag) {
122 bar.style.display = 'none';
123 return;
124 }
125
126 bar.style.display = 'flex';
127 bar.innerHTML = '';
128
129 // "All" button
130 const allBtn = document.createElement('button');
131 allBtn.className = 'btn btn-small tag-filter-btn' + (!currentTag ? ' active' : '');
132 allBtn.textContent = 'All';
133 allBtn.onclick = () => load(null);
134 bar.appendChild(allBtn);
135
136 for (const tag of tags) {
137 const btn = document.createElement('button');
138 btn.className = 'btn btn-small tag-filter-btn' + (currentTag === tag ? ' active' : '');
139 btn.textContent = tag;
140 btn.onclick = () => load(tag);
141 bar.appendChild(btn);
142 }
143
144 // "Add Bookmark" button
145 const addBtn = document.createElement('button');
146 addBtn.className = 'btn btn-small btn-primary';
147 addBtn.textContent = '+ Add';
148 addBtn.onclick = () => openAddModal();
149 bar.appendChild(addBtn);
150 }
151
152 /**
153 * Open the system browser for a URL.
154 * @param {string} url - URL to open.
155 */
156 function openUrl(url) {
157 if (url) {
158 window.__TAURI__.shell.open(url).catch(() => {
159 BB.ui.showToast('Failed to open URL', 'error');
160 });
161 }
162 }
163
164 /**
165 * Show a context menu with actions for a bookmark.
166 * F5 (2026-06-02): delegated to BB.ui.showContextMenu — the canonical
167 * helper was factored from this file's original pattern.
168 * @param {Event} event - Click event for positioning.
169 * @param {string} id - Bookmark ID.
170 */
171 function showContextMenu(event, id) {
172 BB.ui.showContextMenu(event, [
173 { label: 'Edit', fn: () => openEditModal(id) },
174 { label: 'Set Tags', fn: () => openTagsModal(id) },
175 { label: 'Export as HTML', fn: () => exportHtml(id) },
176 { label: 'Delete', fn: () => deleteBookmark(id), danger: true },
177 ]);
178 }
179
180 /**
181 * Open a modal to add a new bookmark by URL.
182 * @param {Object} [prefill] - Optional prefill values (url, title).
183 */
184 function openAddModal(prefill) {
185 prefill = prefill || {};
186 const body = document.getElementById('modal-body');
187 const title = document.getElementById('modal-title');
188 title.textContent = 'Add Bookmark';
189 body.innerHTML = '';
190
191 const form = document.createElement('div');
192 form.className = 'settings-content';
193
194 form.innerHTML = `
195 <div class="form-group">
196 <label>URL</label>
197 <input type="url" id="bm-url" class="form-input" placeholder="https://..." value="${escapeAttr(prefill.url || '')}">
198 </div>
199 <div class="form-group">
200 <label>Title</label>
201 <input type="text" id="bm-title" class="form-input" placeholder="Page title" value="${escapeAttr(prefill.title || '')}">
202 </div>
203 <div class="form-group">
204 <label>Tags (comma-separated)</label>
205 <input type="text" id="bm-tags" class="form-input" placeholder="reading, tools, reference">
206 </div>
207 <div class="form-group">
208 <label>Notes</label>
209 <textarea id="bm-notes" class="form-input" rows="3" placeholder="Personal notes..."></textarea>
210 </div>
211 <div class="form-actions">
212 <button class="btn" id="bm-cancel">Cancel</button>
213 <button class="btn btn-primary" id="bm-save">Save</button>
214 </div>
215 `;
216
217 body.appendChild(form);
218 BB.ui.openModal();
219
220 document.getElementById('bm-cancel').onclick = BB.ui.closeModal;
221 document.getElementById('bm-save').onclick = async () => {
222 const url = document.getElementById('bm-url').value.trim();
223 const bmTitle = document.getElementById('bm-title').value.trim();
224 const tags = document.getElementById('bm-tags').value.split(',').map(t => t.trim()).filter(Boolean);
225 const notes = document.getElementById('bm-notes').value.trim();
226
227 if (!url) {
228 BB.ui.showToast('URL is required', 'error');
229 return;
230 }
231
232 try {
233 await BB.api.bookmarks.create({
234 url: url,
235 title: bmTitle || url,
236 tags: tags,
237 notes: notes,
238 });
239 BB.ui.closeModal();
240 BB.ui.showToast('Bookmark added');
241 updateBadge();
242 if (BB.state.currentSource === '__saved__') load(currentTag);
243 } catch (err) {
244 BB.ui.showToast('Failed to add bookmark: ' + getErrorMessage(err), 'error');
245 }
246 };
247
248 document.getElementById('bm-url').focus();
249 }
250
251 /**
252 * Open a modal to edit a bookmark.
253 * @param {string} id - Bookmark ID.
254 */
255 async function openEditModal(id) {
256 try {
257 const bookmarks = await BB.api.bookmarks.list();
258 const bm = bookmarks.find(b => b.id === id);
259 if (!bm) { BB.ui.showToast('Bookmark not found', 'error'); return; }
260
261 const body = document.getElementById('modal-body');
262 const title = document.getElementById('modal-title');
263 title.textContent = 'Edit Bookmark';
264 body.innerHTML = '';
265
266 const form = document.createElement('div');
267 form.className = 'settings-content';
268
269 form.innerHTML = `
270 <div class="form-group">
271 <label>Title</label>
272 <input type="text" id="bm-edit-title" class="form-input" value="${escapeAttr(bm.title)}">
273 </div>
274 <div class="form-group">
275 <label>Description</label>
276 <textarea id="bm-edit-desc" class="form-input" rows="2">${escapeHtml(bm.description)}</textarea>
277 </div>
278 <div class="form-group">
279 <label>Notes</label>
280 <textarea id="bm-edit-notes" class="form-input" rows="3">${escapeHtml(bm.notes)}</textarea>
281 </div>
282 <div class="form-group">
283 <label><input type="checkbox" id="bm-edit-pinned" ${bm.isPinned ? 'checked' : ''}> Pin to top</label>
284 </div>
285 <div class="form-actions">
286 <button class="btn" id="bm-edit-cancel">Cancel</button>
287 <button class="btn btn-primary" id="bm-edit-save">Save</button>
288 </div>
289 `;
290
291 body.appendChild(form);
292 BB.ui.openModal();
293
294 document.getElementById('bm-edit-cancel').onclick = BB.ui.closeModal;
295 document.getElementById('bm-edit-save').onclick = async () => {
296 try {
297 await BB.api.bookmarks.update(id, {
298 title: document.getElementById('bm-edit-title').value.trim() || null,
299 description: document.getElementById('bm-edit-desc').value.trim() || null,
300 notes: document.getElementById('bm-edit-notes').value.trim() || null,
301 isPinned: document.getElementById('bm-edit-pinned').checked,
302 });
303 BB.ui.closeModal();
304 BB.ui.showToast('Bookmark updated');
305 if (BB.state.currentSource === '__saved__') load(currentTag);
306 } catch (err) {
307 BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
308 }
309 };
310 } catch (err) {
311 BB.ui.showToast('Failed to load bookmark: ' + getErrorMessage(err), 'error');
312 }
313 }
314
315 /**
316 * Open a modal to set tags on a bookmark.
317 * @param {string} id - Bookmark ID.
318 */
319 async function openTagsModal(id) {
320 try {
321 const bookmarks = await BB.api.bookmarks.list();
322 const bm = bookmarks.find(b => b.id === id);
323 if (!bm) { BB.ui.showToast('Bookmark not found', 'error'); return; }
324
325 const body = document.getElementById('modal-body');
326 const title = document.getElementById('modal-title');
327 title.textContent = 'Set Tags';
328 body.innerHTML = '';
329
330 const form = document.createElement('div');
331 form.className = 'settings-content';
332
333 form.innerHTML = `
334 <div class="form-group">
335 <label>Tags (comma-separated)</label>
336 <input type="text" id="bm-tags-input" class="form-input" value="${escapeAttr(bm.tags.join(', '))}">
337 </div>
338 <div class="form-actions">
339 <button class="btn" id="bm-tags-cancel">Cancel</button>
340 <button class="btn btn-primary" id="bm-tags-save">Save</button>
341 </div>
342 `;
343
344 body.appendChild(form);
345 BB.ui.openModal();
346
347 document.getElementById('bm-tags-cancel').onclick = BB.ui.closeModal;
348 document.getElementById('bm-tags-save').onclick = async () => {
349 const tags = document.getElementById('bm-tags-input').value
350 .split(',').map(t => t.trim()).filter(Boolean);
351 try {
352 await BB.api.bookmarks.setTags(id, tags);
353 BB.ui.closeModal();
354 BB.ui.showToast('Tags updated');
355 if (BB.state.currentSource === '__saved__') load(currentTag);
356 } catch (err) {
357 BB.ui.showToast('Failed to update tags: ' + getErrorMessage(err), 'error');
358 }
359 };
360 } catch (err) {
361 BB.ui.showToast('Failed to load bookmark: ' + getErrorMessage(err), 'error');
362 }
363 }
364
365 /**
366 * Toggle the pinned state of a bookmark.
367 * @param {string} id - Bookmark ID.
368 * @param {boolean} isPinned - Current pinned state.
369 */
370 async function togglePin(id, isPinned) {
371 try {
372 await BB.api.bookmarks.update(id, { isPinned: !isPinned });
373 BB.ui.showToast(isPinned ? 'Unpinned' : 'Pinned');
374 if (BB.state.currentSource === '__saved__') load(currentTag);
375 } catch (err) {
376 BB.ui.showToast('Failed to update pin: ' + getErrorMessage(err), 'error');
377 }
378 }
379
380 /**
381 * Delete a bookmark with confirmation.
382 * @param {string} id - Bookmark ID.
383 */
384 async function deleteBookmark(id) {
385 const ok = await BB.ui.confirmAction('Remove this bookmark?');
386 if (!ok) return;
387 try {
388 await BB.api.bookmarks.delete(id);
389 BB.ui.showToast('Bookmark removed');
390 updateBadge();
391 if (BB.state.currentSource === '__saved__') load(currentTag);
392 } catch (err) {
393 BB.ui.showToast('Failed to delete: ' + getErrorMessage(err), 'error');
394 }
395 }
396
397 /**
398 * Export a bookmark as HTML and trigger a download.
399 * @param {string} id - Bookmark ID.
400 */
401 async function exportHtml(id) {
402 try {
403 const html = await BB.api.bookmarks.exportHtml(id);
404 const blob = new Blob([html], { type: 'text/html' });
405 const url = URL.createObjectURL(blob);
406 const a = document.createElement('a');
407 a.href = url;
408 a.download = 'bookmark.html';
409 document.body.appendChild(a);
410 a.click();
411 document.body.removeChild(a);
412 URL.revokeObjectURL(url);
413 BB.ui.showToast('HTML exported');
414 } catch (err) {
415 BB.ui.showToast('Export failed: ' + getErrorMessage(err), 'error');
416 }
417 }
418
419 /**
420 * Bookmark the currently displayed feed item from the detail panel.
421 */
422 async function bookmarkCurrentItem() {
423 const item = BB.detail.getCurrentItem();
424 if (!item) return;
425
426 try {
427 // Check if already bookmarked
428 if (item.url) {
429 const already = await BB.api.bookmarks.isBookmarked(item.url);
430 if (already) {
431 BB.ui.showToast('Already bookmarked');
432 return;
433 }
434 }
435 await BB.api.bookmarks.createFromItem(item.id);
436 BB.ui.showToast('Added to Reading List');
437 updateBadge();
438 } catch (err) {
439 BB.ui.showToast('Failed to bookmark: ' + getErrorMessage(err), 'error');
440 }
441 }
442
443 /** Update the sidebar reading list badge count. */
444 async function updateBadge() {
445 try {
446 const count = await BB.api.bookmarks.count();
447 const el = document.getElementById('saved-count');
448 if (el) el.textContent = count;
449 } catch {
450 // Non-critical
451 }
452 }
453
454 /**
455 * One-time migration from localStorage saved items to database bookmarks.
456 * Runs on app startup, silently skips items that no longer exist.
457 */
458 async function migrateFromLocalStorage() {
459 const SAVED_KEY = 'bb-saved-items';
460 let savedIds;
461 try {
462 savedIds = JSON.parse(localStorage.getItem(SAVED_KEY));
463 } catch {
464 return;
465 }
466 if (!savedIds || !Array.isArray(savedIds) || savedIds.length === 0) return;
467
468 let migrated = 0;
469 for (const id of savedIds) {
470 try {
471 await BB.api.bookmarks.createFromItem(id);
472 migrated++;
473 } catch {
474 // Item may no longer exist or already bookmarked — skip
475 }
476 }
477 localStorage.removeItem(SAVED_KEY);
478 if (migrated > 0) {
479 BB.ui.showToast('Migrated ' + migrated + ' saved article' + (migrated === 1 ? '' : 's') + ' to Reading List');
480 updateBadge();
481 }
482 }
483
484 BB.bookmarks = {
485 load, openAddModal, openEditModal, openTagsModal, togglePin,
486 deleteBookmark, exportHtml, bookmarkCurrentItem, updateBadge,
487 migrateFromLocalStorage, openUrl, showContextMenu,
488 };
489 })();
490