New plugin at public/plugins/bookmarks/ — bookmark lists stored as
cards tagged #bookmarks with a JSON body. Board cards render as
coloured tiles (70% card colour + surface) with a centred link icon,
title, and date/bucket metadata; no body preview.
Clicking opens a full-screen overlay with:
- Named columns (click header to rename, right-click for
rename + delete), + button to add columns
- Bookmark items: favicon (stored as data: URI via server-side
fetch-meta) + title with ellipsis. Click opens URL in new tab.
- Right-click item → rename or delete
- Positional drag-and-drop between and within columns — accent
drop-indicator line shows the insertion point
- Full-width slider sets relative column width, persisted per card
- Horizontal wheel-to-scroll on the column container
Adding a bookmark reads the clipboard; if it contains a URL, the
backend fetches <title> + favicon (as base64 data: URI, ≤32 KB) so
the card is fully self-contained. Non-URL clipboard → manual entry
dialog. Also accepts card:123 links — rendered with a card icon in
the linked card's colour; click opens the linked card's detail view
or, if it's another bookmark list, switches the overlay to it.
Multiple bookmark lists supported: header button always shows a
picker (even with one list) with a "new list" option. New lists
inherit the active bucket. Overlay title is click-to-rename.
Tests: plugin registration, header button injection, board card
decoration, overlay open/close + column + item rendering, fetch-meta
API shape, card-link icon rendering, column-width persistence.
Also fixes the flaky undo due_date test — replaced a fixed sleep
with waitForStateCard polling so the undo PATCH has time to settle.
Bumps to 1.9.23.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
615 lines
25 KiB
JavaScript
615 lines
25 KiB
JavaScript
// ── 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, '"').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 = `
|
|
<div class="bm-card-inner">
|
|
<div class="bm-card-center">
|
|
<i class="ph ph-link bm-card-icon"></i>
|
|
<span class="bm-card-title">${esc(card.title)}</span>
|
|
</div>
|
|
<div class="bm-card-meta">
|
|
<span><i class="ph ph-clock"></i>${esc(date)}</span>
|
|
${bucket ? `<span><i class="ph ph-folder"></i>${esc(bucket)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span class="bm-col-name" title="${esc(col.name)}">${esc(col.name)}</span>
|
|
<button class="bm-col-add" data-col="${ci}" title="add bookmark"><i class="ph ph-plus"></i></button>
|
|
`;
|
|
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 = '<div class="bm-col-empty">no bookmarks</div>';
|
|
} 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 = `<i class="ph ph-card bm-favicon-placeholder" style="color:${esc(linkedColor)}"></i>`;
|
|
} else {
|
|
fav = it.favicon
|
|
? `<img class="bm-favicon" src="${esc(it.favicon)}" onerror="this.outerHTML='<i class=\\'ph ph-globe bm-favicon-placeholder\\'></i>'">`
|
|
: '<i class="ph ph-globe bm-favicon-placeholder"></i>';
|
|
}
|
|
el.innerHTML = `${fav}<span class="bm-item-title" title="${esc(it.title || it.url)}">${esc(it.title || it.url)}</span>`;
|
|
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 = `<i class="ph ${icon} text-sm"></i>${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 = '<i class="ph ph-link text-sm"></i><span class="font-pixel text-[10px]">Bookmarks</span>';
|
|
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 = `<span style="width:10px;height:10px;border-radius:2px;background:${c.color || '#38bdf8'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(c.title)}</span>`;
|
|
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 = '<i class="ph ph-plus text-xs"></i> 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',
|
|
});
|