// ── Bookmarks Plugin ── // // A bookmark list is a card tagged #bookmarks. Its body is a JSON // blob: { type:"bookmarks", columns:[{name, items:[{url,title,favicon}]}], columnWidth }. // On the board the card renders as a coloured tile with a link icon // and no body preview. Clicking opens a column-based overlay; each // item is a small box with favicon + title. Click → open URL. // // Phase 1+2: skeleton, board decoration, overlay (read + write), // add/delete/rename, clipboard paste + server-side meta fetch. const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[bookmarks] window.hyprcrd missing'); const $ = (id) => document.getElementById(id); function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } const TAG = 'bookmarks'; let activeCardId = null; // card id currently open in the overlay let data = null; // parsed JSON body of the active card // ── Helpers ────────────────────────────────────────────────────── function isBookmarkCard(card) { return (card.tags || '').split(',').map(s => s.trim()).includes(TAG); } function emptyData() { return { type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 }; } function parseBody(body) { const text = (body || '').trim(); // Full string might be pure JSON (legacy) or JSON + trailing // "\n\n#bookmarks" tag line that extractTags needs on the server. try { const d = JSON.parse(text); if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d; } catch {} // Strip trailing tag line and retry. const jsonEnd = text.lastIndexOf('}'); if (jsonEnd >= 0) { try { const d = JSON.parse(text.substring(0, jsonEnd + 1)); if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d; } catch {} } return emptyData(); } function isUrl(s) { return /^https?:\/\/.+/i.test((s || '').trim()); } function isCardLink(s) { return /^card:\d+$/i.test((s || '').trim()); } function parseCardId(s) { const m = /^card:(\d+)$/i.exec((s || '').trim()); return m ? m[1] : null; } async function save() { if (!activeCardId || !data) return; // The trailing #bookmarks tag is essential: the server's // extractTags() parses hashtags from the body text to populate // the `tags` column. Without it, isBookmarkCard (which checks // tags, not body) wouldn't recognise the card on meta-only // loads where body is still null. const body = JSON.stringify(data) + '\n\n#bookmarks'; await hyprcrd.api(`api/cards/${activeCardId}`, { method: 'PATCH', body: { body }, }); } // ── Board card decoration ──────────────────────────────────────── // MutationObserver on #board: any card whose tags include "bookmarks" // gets the .bm-card class + a link icon. Click is intercepted to // open the overlay instead of the detail view. function decorateBoard() { const board = $('board'); if (!board) return; const cards = window.state?.cards || []; board.querySelectorAll('.board-card[data-id]').forEach(el => { const card = cards.find(c => String(c.id) === el.dataset.id); if (!card || !isBookmarkCard(card)) return; if (el.dataset.bmDone) return; el.dataset.bmDone = '1'; el.classList.add('bm-card'); // Replace the entire card content with a coloured tile // showing just a big link icon and the title. The original // innerHTML (bar + body preview + tags + meta) is noise for // a bookmark list card. const color = card.color || '#38bdf8'; el.style.background = `color-mix(in srgb, ${color} 70%, var(--t-surface))`; el.style.borderColor = color; const date = (card.updated_at || card.created_at || '').substring(0, 10); const bucket = card.bucket_name || ''; el.innerHTML = `
${esc(card.title)}
${esc(date)} ${bucket ? `${esc(bucket)}` : ''}
`; el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); openOverlay(card.id); }, true); el.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); openOverlay(card.id); }, true); }); } // Re-decorate after every board render. new MutationObserver(decorateBoard).observe( $('board') || document.body, { childList: true, subtree: true } ); // ── Overlay ────────────────────────────────────────────────────── const overlay = $('bookmarks-overlay'); const colsEl = $('bookmarks-columns'); const slider = $('bookmarks-col-width'); async function openOverlay(cardId) { activeCardId = cardId; let card = (window.state?.cards || []).find(c => c.id == cardId); if (!card) return; // Body might still be null if Phase-2 hasn't loaded it yet. // Fetch it explicitly so we don't clobber the stored JSON // (including columnWidth) with emptyData() defaults. if (card.body == null) { const full = await hyprcrd.api('api/cards?ids=' + cardId); if (Array.isArray(full) && full[0]) { const idx = (window.state?.cards || []).findIndex(c => c.id == cardId); if (idx >= 0) window.state.cards[idx] = full[0]; card = full[0]; } } $('bookmarks-title').textContent = card.title || 'Bookmarks'; data = parseBody(card.body); if (slider) slider.value = data.columnWidth || 220; applyColumnWidth(); renderColumns(); overlay?.classList.add('open'); } function closeOverlay() { overlay?.classList.remove('open'); activeCardId = null; data = null; hideCtx(); } function applyColumnWidth() { const w = slider ? parseInt(slider.value) : 220; colsEl?.style.setProperty('--bm-col-w', w + 'px'); if (data) data.columnWidth = w; } function renderColumns() { if (!colsEl || !data) return; colsEl.innerHTML = ''; data.columns.forEach((col, ci) => { const div = document.createElement('div'); div.className = 'bm-col'; div.dataset.col = ci; // Header const hdr = document.createElement('div'); hdr.className = 'bm-col-header'; hdr.innerHTML = ` ${esc(col.name)} `; hdr.querySelector('.bm-col-name').addEventListener('click', () => renameColumn(ci)); hdr.querySelector('.bm-col-name').addEventListener('contextmenu', (e) => { e.preventDefault(); showColCtx(ci, e.clientX, e.clientY); }); hdr.querySelector('.bm-col-add').addEventListener('click', () => addBookmark(ci)); div.appendChild(hdr); // Items const items = document.createElement('div'); items.className = 'bm-col-items'; items.dataset.col = ci; if (!col.items.length) { items.innerHTML = '
no bookmarks
'; } else { col.items.forEach((it, ii) => { const el = document.createElement('div'); el.className = 'bm-item'; el.dataset.col = ci; el.dataset.idx = ii; el.draggable = true; const cardId = parseCardId(it.url); const isCard = !!cardId; let fav; if (isCard) { // Card link: show a card icon instead of a web favicon. const linked = (window.state?.cards || []).find(c => String(c.id) === cardId); const linkedColor = linked?.color || 'var(--t-accent)'; fav = ``; } else { fav = it.favicon ? `` : ''; } el.innerHTML = `${fav}${esc(it.title || it.url)}`; el.addEventListener('click', () => { if (isCard) { // Check if the linked card is itself a bookmark list. const linked = (window.state?.cards || []).find(c => String(c.id) === cardId); if (linked && isBookmarkCard(linked)) { openOverlay(linked.id); } else { closeOverlay(); window._openDetail?.(parseInt(cardId)); } return; } if (it.url) window.open(it.url, '_blank', 'noopener'); }); el.addEventListener('contextmenu', (e) => { e.preventDefault(); showItemCtx(ci, ii, e.clientX, e.clientY); }); // Drag el.addEventListener('dragstart', (e) => { e.dataTransfer.setData('application/x-bm', JSON.stringify({ col: ci, idx: ii })); e.dataTransfer.effectAllowed = 'move'; el.classList.add('dragging'); }); el.addEventListener('dragend', () => el.classList.remove('dragging')); items.appendChild(el); }); } // Drop target — positional: a drop indicator line appears // between items based on the cursor's vertical position. // The item is inserted AT that position, not always at the end. items.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('application/x-bm')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; // Show a drop indicator between items. clearDropIndicator(items); const after = getDropIndex(items, e.clientY); const children = [...items.querySelectorAll('.bm-item')]; if (after < children.length) { children[after].classList.add('bm-drop-above'); } else if (children.length) { children[children.length - 1].classList.add('bm-drop-below'); } }); items.addEventListener('dragleave', () => clearDropIndicator(items)); items.addEventListener('drop', (e) => { clearDropIndicator(items); const raw = e.dataTransfer.getData('application/x-bm'); if (!raw) return; e.preventDefault(); const src = JSON.parse(raw); const destCol = ci; if (!data) return; const [item] = data.columns[src.col].items.splice(src.idx, 1); if (!item) return; // Compute insertion index from cursor position. const insertAt = getDropIndex(items, e.clientY); // If source and dest are the same column, the splice above // shifted indices — getDropIndex already sees the shortened // list, so insertAt is correct as-is. data.columns[destCol].items.splice(insertAt, 0, item); save(); renderColumns(); }); div.appendChild(items); colsEl.appendChild(div); }); } // ── Drag-and-drop helpers ───────────────────────────────────────── function getDropIndex(container, clientY) { const children = [...container.querySelectorAll('.bm-item')]; for (let i = 0; i < children.length; i++) { const r = children[i].getBoundingClientRect(); if (clientY < r.top + r.height / 2) return i; } return children.length; } function clearDropIndicator(container) { container.querySelectorAll('.bm-drop-above,.bm-drop-below').forEach(el => { el.classList.remove('bm-drop-above', 'bm-drop-below'); }); } // ── Wheel → horizontal scroll ──────────────────────────────────── colsEl?.addEventListener('wheel', (e) => { if (e.deltaY === 0) return; if (colsEl.scrollWidth <= colsEl.clientWidth) return; e.preventDefault(); colsEl.scrollLeft += e.deltaY; }, { passive: false }); // ── Slider ─────────────────────────────────────────────────────── slider?.addEventListener('input', () => { applyColumnWidth(); renderColumns(); }); slider?.addEventListener('change', () => save()); // ── Context menus ──────────────────────────────────────────────── let ctxEl = null; function hideCtx() { ctxEl?.remove(); ctxEl = null; } function showCtx(x, y, buttons) { hideCtx(); ctxEl = document.createElement('div'); ctxEl.className = 'bm-ctx'; ctxEl.style.left = x + 'px'; ctxEl.style.top = y + 'px'; buttons.forEach(([label, icon, cls, fn]) => { const b = document.createElement('button'); if (cls) b.className = cls; b.innerHTML = `${esc(label)}`; b.addEventListener('click', () => { hideCtx(); fn(); }); ctxEl.appendChild(b); }); document.body.appendChild(ctxEl); // Clamp to viewport const r = ctxEl.getBoundingClientRect(); if (r.right > window.innerWidth - 8) ctxEl.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px'; if (r.bottom > window.innerHeight - 8) ctxEl.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { const off = (e) => { if (!ctxEl?.contains(e.target)) { hideCtx(); document.removeEventListener('mousedown', off, true); } }; document.addEventListener('mousedown', off, true); }, 0); } function showItemCtx(col, idx, x, y) { const item = data?.columns?.[col]?.items?.[idx]; if (!item) return; showCtx(x, y, [ ['Rename', 'ph-pencil-simple', '', async () => { const name = await hyprcrd.dialog.show({ title: 'rename bookmark', input: true, defaultValue: item.title || '', okLabel: 'Save' }); if (name === false || name === null) return; item.title = name; await save(); renderColumns(); }], ['Delete', 'ph-trash', 'danger', async () => { data.columns[col].items.splice(idx, 1); await save(); renderColumns(); }], ]); } async function renameColumn(col) { const column = data?.columns?.[col]; if (!column) return; const name = await hyprcrd.dialog.show({ title: 'rename column', input: true, defaultValue: column.name, okLabel: 'Save' }); if (name === false || name === null || name.trim() === '') return; column.name = name.trim(); await save(); renderColumns(); } function showColCtx(col, x, y) { const column = data?.columns?.[col]; if (!column) return; showCtx(x, y, [ ['Rename column', 'ph-pencil-simple', '', () => renameColumn(col)], ['Delete column', 'ph-trash', 'danger', async () => { if (column.items.length) { const ok = await hyprcrd.dialog.show({ title: 'delete column', message: `"${column.name}" has ${column.items.length} bookmark(s). Delete anyway?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } data.columns.splice(col, 1); if (!data.columns.length) data.columns.push({ name: 'General', items: [] }); await save(); renderColumns(); }], ]); } // ── Add bookmark (clipboard + meta-fetch) ──────────────────────── async function addBookmark(colIdx) { let url = ''; try { const clip = await navigator.clipboard.readText(); if (isUrl(clip) || isCardLink(clip)) url = clip.trim(); } catch {} if (!url) { const entered = await hyprcrd.dialog.show({ title: 'new bookmark', input: true, placeholder: 'https://... or card:123', okLabel: 'Add', }); if (!entered || !entered.trim()) return; url = entered.trim(); } const item = { url, title: url, favicon: '' }; if (isCardLink(url)) { // Resolve card title for the display label. const cid = parseCardId(url); const card = (window.state?.cards || []).find(c => String(c.id) === cid); if (card) item.title = card.title || url; } else if (isUrl(url)) { try { const meta = await hyprcrd.api('api/bookmarks/fetch-meta?url=' + encodeURIComponent(url)); if (meta?.ok) { if (meta.title) item.title = meta.title; if (meta.favicon) item.favicon = meta.favicon; if (meta.url) item.url = meta.url; } } catch {} } data.columns[colIdx].items.push(item); await save(); renderColumns(); } // ── Add column ─────────────────────────────────────────────────── async function addColumn() { const name = await hyprcrd.dialog.show({ title: 'new column', input: true, placeholder: 'column name', okLabel: 'Create', }); if (!name || !name.trim()) return; data.columns.push({ name: name.trim(), items: [] }); await save(); renderColumns(); // Scroll to the new column setTimeout(() => { colsEl.scrollLeft = colsEl.scrollWidth; }, 50); } // ── Header button injection ────────────────────────────────────── // Creates a new #bookmarks card (or opens the most recent one). function injectHeaderButton() { if ($('bookmarks-btn')) return; const header = document.querySelector('header#main-header'); if (!header) return; const rows = header.querySelectorAll('.flex.items-center.gap-3'); const group = rows[rows.length - 1]; if (!group) return; const btn = document.createElement('button'); btn.id = 'bookmarks-btn'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.innerHTML = 'Bookmarks'; btn.addEventListener('click', () => { const cards = (window.state?.cards || []).filter(isBookmarkCard) .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')); if (cards.length === 0) { createBookmarkCard(); } else { // Always show picker so the user can create new lists // even when only one exists. showBookmarkPicker(btn, cards); } }); group.insertBefore(btn, group.firstChild); } // ── Rename the currently open bookmark card ────────────────── async function renameCard() { if (!activeCardId) return; const card = (window.state?.cards || []).find(c => c.id == activeCardId); const current = card?.title || ''; const name = await hyprcrd.dialog.show({ title: 'rename bookmark list', input: true, defaultValue: current, okLabel: 'Save', }); if (name === false || name === null || !name.trim()) return; await hyprcrd.api(`api/cards/${activeCardId}`, { method: 'PATCH', body: { title: name.trim() }, }); $('bookmarks-title').textContent = name.trim(); await window._reloadAll?.(); } // ── Create bookmark card with name prompt ──────────────────── async function createBookmarkCard() { const name = await hyprcrd.dialog.show({ title: 'new bookmark list', input: true, placeholder: 'list name...', okLabel: 'Create', }); if (!name || !name.trim()) return; const active = window.state?.activeBucket; const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal']; const bucketId = active && !virtualBuckets.includes(active) ? active : null; const created = await hyprcrd.api('api/cards', { method: 'POST', body: { title: name.trim(), body: JSON.stringify(emptyData()) + '\n\n#bookmarks', color: '#38bdf8', bucket_id: bucketId, }, }); if (created?.id) { await window._reloadAll?.(); openOverlay(created.id); } } // ── Picker popup (multiple bookmark cards) ─────────────────── function showBookmarkPicker(anchor, cards) { document.querySelector('.bm-picker')?.remove(); const rect = anchor.getBoundingClientRect(); const pop = document.createElement('div'); pop.className = 'bm-picker'; pop.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.bottom + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:200px;max-height:300px;display:flex;flex-direction:column;border-radius:var(--ui-radius-modal,4px);`; const list = document.createElement('div'); list.style.cssText = 'overflow-y:auto;flex:1;'; cards.forEach(c => { const row = document.createElement('button'); row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;'; row.innerHTML = `${esc(c.title)}`; row.addEventListener('mouseenter', () => { row.style.color = 'var(--t-bright)'; }); row.addEventListener('mouseleave', () => { row.style.color = 'var(--t-muted)'; }); row.addEventListener('click', () => { pop.remove(); openOverlay(c.id); }); list.appendChild(row); }); pop.appendChild(list); const addBtn = document.createElement('button'); addBtn.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px;background:none;border:none;border-top:1px solid var(--t-border);color:var(--t-accent);font-size:10px;font-family:var(--t-font-pixel);cursor:pointer;text-align:left;'; addBtn.innerHTML = ' new list'; addBtn.addEventListener('click', () => { pop.remove(); createBookmarkCard(); }); pop.appendChild(addBtn); document.body.appendChild(pop); // Clamp const r = pop.getBoundingClientRect(); if (r.right > window.innerWidth - 8) pop.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px'; if (r.bottom > window.innerHeight - 8) pop.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px'; const close = (e) => { if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener('mousedown', close, true); } }; setTimeout(() => document.addEventListener('mousedown', close, true), 0); } // ── Init ───────────────────────────────────────────────────────── function init() { injectHeaderButton(); $('bookmarks-close')?.addEventListener('click', closeOverlay); $('bookmarks-col-add')?.addEventListener('click', addColumn); $('bookmarks-add-btn')?.addEventListener('click', () => { if (data?.columns?.length) addBookmark(0); }); $('bookmarks-title')?.addEventListener('click', renameCard); overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlay?.classList.contains('open')) { e.stopPropagation(); closeOverlay(); } }, true); decorateBoard(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } hyprcrd.registerPlugin?.({ name: 'bookmarks', version: '1.0.0', description: 'Column-based bookmark manager stored as cards', });