bookmarks: column-based bookmark manager plugin
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>
This commit is contained in:
1 parent
883640e20c
commit
82b2ebc15f
10 files changed
+1157
-6
No files matched your search
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bookmarks Plugin: backend.
|
||||
*
|
||||
* Single endpoint that fetches a URL's <title> and favicon so the
|
||||
* frontend doesn't have to deal with CORS. Light and stateless —
|
||||
* the bookmarks themselves live in the card body (JSON blob).
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/bookmarks/fetch-meta?url=... → { title, favicon }
|
||||
*/
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return;
|
||||
|
||||
$url = trim((string)($_GET['url'] ?? ''));
|
||||
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'valid url required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$html = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
||||
curl_close($ch);
|
||||
|
||||
$title = '';
|
||||
if ($html && $status >= 200 && $status < 400) {
|
||||
// Extract <title>
|
||||
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
|
||||
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
// og:title fallback
|
||||
if ($title === '' && preg_match('/<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) {
|
||||
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
}
|
||||
|
||||
// Favicon: resolve the URL, then download the bytes and return a
|
||||
// self-contained data: URI so the card body is fully offline-capable
|
||||
// and opening the overlay doesn't leak requests to every bookmarked
|
||||
// site. Caps at 32 KB — anything bigger is probably not an icon.
|
||||
$parsed = parse_url($finalUrl);
|
||||
$origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$faviconUrl = $origin . '/favicon.ico';
|
||||
if ($html && preg_match('/<link[^>]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) {
|
||||
$href = $m[1];
|
||||
if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href;
|
||||
elseif (str_starts_with($href, '/')) $href = $origin . $href;
|
||||
elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href;
|
||||
$faviconUrl = $href;
|
||||
}
|
||||
|
||||
$faviconData = '';
|
||||
$ch2 = curl_init($faviconUrl);
|
||||
curl_setopt_array($ch2, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||
]);
|
||||
$favBytes = curl_exec($ch2);
|
||||
$favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||
$favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
|
||||
curl_close($ch2);
|
||||
|
||||
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
||||
$mime = preg_replace('/;.*$/', '', $favMime);
|
||||
$faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'url' => $finalUrl,
|
||||
'title' => mb_substr($title, 0, 200),
|
||||
'favicon' => $faviconData,
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/* ── Bookmarks plugin ── */
|
||||
|
||||
/* Board card override: the MutationObserver replaces the card's
|
||||
innerHTML with a coloured tile (card.color as bg) showing a
|
||||
centered link icon + the title. No body preview, no tags, no
|
||||
metadata — just a clean, recognisable tile. */
|
||||
.board-card.bm-card {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bm-card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
.bm-card-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px 14px 8px;
|
||||
}
|
||||
.bm-card-icon {
|
||||
font-size: 42px;
|
||||
color: rgba(0,0,0,0.2);
|
||||
}
|
||||
.bm-card-title {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 11px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.bm-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
.bm-card-meta i { font-size: 10px; margin-right: 2px; }
|
||||
|
||||
/* Overlay shell */
|
||||
.bookmarks-shell {
|
||||
position: absolute;
|
||||
inset: 40px;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-modal, 4px);
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bookmarks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bm-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-muted);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.bm-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||
|
||||
/* Column width slider — full width */
|
||||
.bookmarks-slider-row {
|
||||
padding: 6px 16px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bookmarks-slider {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--t-border);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bookmarks-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--t-accent); cursor: pointer; border: none;
|
||||
}
|
||||
.bookmarks-slider::-moz-range-thumb {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--t-accent); cursor: pointer; border: none;
|
||||
}
|
||||
|
||||
/* Column container — horizontal scroll, wheel redirected by JS */
|
||||
.bookmarks-columns {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.bookmarks-columns::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Single column */
|
||||
.bm-col {
|
||||
flex: 0 0 var(--bm-col-w, 220px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bm-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bm-col-header .bm-col-name {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
color: var(--t-bright);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.bm-col-header .bm-col-name:hover { color: var(--t-accent); }
|
||||
.bm-col-header .bm-col-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--t-dim);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.bm-col-header .bm-col-add:hover { color: var(--t-accent); }
|
||||
|
||||
.bm-col-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Bookmark item */
|
||||
.bm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
background: var(--t-raised, var(--t-surface));
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-card, 3px);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
min-width: 0;
|
||||
}
|
||||
.bm-item:hover {
|
||||
border-color: var(--t-accent);
|
||||
background: color-mix(in srgb, var(--t-accent) 6%, var(--t-surface));
|
||||
}
|
||||
.bm-item .bm-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.bm-item .bm-favicon-placeholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--t-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.bm-item .bm-item-title {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--t-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bm-item:hover .bm-item-title { color: var(--t-bright); }
|
||||
.bm-item.dragging { opacity: 0.4; }
|
||||
.bm-item.bm-drop-above {
|
||||
box-shadow: 0 -2px 0 0 var(--t-accent);
|
||||
}
|
||||
.bm-item.bm-drop-below {
|
||||
box-shadow: 0 2px 0 0 var(--t-accent);
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
.bm-ctx {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
border-radius: var(--ui-radius-modal, 4px);
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.bm-ctx button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--t-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.08s, color 0.08s;
|
||||
}
|
||||
.bm-ctx button:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||
.bm-ctx button.danger:hover { color: var(--t-danger); }
|
||||
|
||||
/* Empty column state */
|
||||
.bm-col-empty {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--t-dim);
|
||||
font-family: var(--t-font-pixel);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- Bookmarks overlay. Opens when the user clicks a #bookmarks card
|
||||
on the board (instead of the normal detail view). Column-based
|
||||
layout; each column is a topic, each item is a bookmark with
|
||||
favicon + title. -->
|
||||
<div id="bookmarks-overlay" class="overlay fixed inset-0 z-[80]" style="background:var(--t-overlay)">
|
||||
<div class="bookmarks-shell">
|
||||
<div class="bookmarks-header">
|
||||
<i class="ph ph-link text-t-accent text-base"></i>
|
||||
<span id="bookmarks-title" class="font-pixel text-sm text-t-accent flex-1 truncate cursor-pointer hover:text-t-bright transition-colors" data-tip="click to rename"></span>
|
||||
<button id="bookmarks-add-btn" class="bm-btn" data-tip="new bookmark (paste URL)"><i class="ph ph-plus text-sm"></i></button>
|
||||
<button id="bookmarks-col-add" class="bm-btn" data-tip="new column"><i class="ph ph-columns-plus-right text-sm"></i></button>
|
||||
<button id="bookmarks-close" class="bm-btn hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="bookmarks-slider-row">
|
||||
<input type="range" id="bookmarks-col-width" min="140" max="400" step="10" value="220" class="bookmarks-slider">
|
||||
</div>
|
||||
<div id="bookmarks-columns" class="bookmarks-columns"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,614 @@
|
||||
// ── 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',
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "bookmarks",
|
||||
"version": "1.0.0",
|
||||
"description": "Bookmark manager stored as cards. Column-based view with favicon fetch, clipboard paste, and drag-and-drop.",
|
||||
"frontend": {
|
||||
"js": ["bookmarks.js"],
|
||||
"css": ["bookmarks.css"],
|
||||
"html": ["bookmarks.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, $, click, sleep, waitFor, csrf } from './runner.js?v=4';
|
||||
|
||||
// Bookmarks plugin tests. The plugin injects a header button, decorates
|
||||
// #bookmarks-tagged board cards, and opens a column-based overlay.
|
||||
// Tests create throwaway cards and clean up in finally blocks.
|
||||
|
||||
const token = () => csrf();
|
||||
const hJson = () => ({ 'Content-Type': 'application/json', 'X-CSRF-Token': token() });
|
||||
const hDel = () => ({ 'X-CSRF-Token': token() });
|
||||
|
||||
function emptyBody() {
|
||||
return JSON.stringify({ type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 }) + '\n\n#bookmarks';
|
||||
}
|
||||
|
||||
function bodyWith(columns, columnWidth = 220) {
|
||||
return JSON.stringify({ type: 'bookmarks', columns, columnWidth }) + '\n\n#bookmarks';
|
||||
}
|
||||
|
||||
describe('Bookmarks: plugin registration', () => {
|
||||
it('registers itself in window.hyprcrd.plugins', () => {
|
||||
const entry = (window.hyprcrd?.plugins || []).find(p => p?.name === 'bookmarks');
|
||||
expect(entry).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: header button', () => {
|
||||
it('injects a Bookmarks button into the header', () => {
|
||||
const btn = $('#bookmarks-btn');
|
||||
expect(btn).toExist();
|
||||
expect(btn.closest('#main-header')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: board card rendering', () => {
|
||||
it('decorates #bookmarks cards with bm-card class', async () => {
|
||||
let card;
|
||||
try {
|
||||
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_deco__', body: emptyBody(), color: '#fb923c' }) }).then(r => r.json());
|
||||
expect(card?.id).toExist();
|
||||
await window._reloadAll();
|
||||
await sleep(200);
|
||||
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||
expect(el).toExist();
|
||||
expect(el.classList.contains('bm-card')).toBe(true);
|
||||
expect(el.querySelector('.bm-card-icon')).toExist();
|
||||
expect(el.querySelector('.bm-card-title').textContent).toContain('__test_bm_deco__');
|
||||
} finally {
|
||||
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
|
||||
await window._reloadAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: overlay', () => {
|
||||
it('opens on click and shows columns from the card body', async () => {
|
||||
const cols = [
|
||||
{ name: 'TestCol', items: [{ url: 'https://example.com', title: 'Example', favicon: '' }] }
|
||||
];
|
||||
let card;
|
||||
try {
|
||||
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_overlay__', body: bodyWith(cols) }) }).then(r => r.json());
|
||||
await window._reloadAll();
|
||||
await sleep(200);
|
||||
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||
expect(el).toExist();
|
||||
click(el);
|
||||
await sleep(150);
|
||||
const overlay = $('#bookmarks-overlay');
|
||||
expect(overlay.classList.contains('open')).toBe(true);
|
||||
expect($('bookmarks-title').textContent).toContain('__test_bm_overlay__');
|
||||
// Column rendered
|
||||
const colName = document.querySelector('.bm-col-name');
|
||||
expect(colName).toExist();
|
||||
expect(colName.textContent).toBe('TestCol');
|
||||
// Item rendered
|
||||
const item = document.querySelector('.bm-item');
|
||||
expect(item).toExist();
|
||||
expect(item.textContent).toContain('Example');
|
||||
// Close
|
||||
click($('bookmarks-close'));
|
||||
await sleep(100);
|
||||
expect(overlay.classList.contains('open')).toBe(false);
|
||||
} finally {
|
||||
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
|
||||
await window._reloadAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: fetch-meta API', () => {
|
||||
it('returns JSON with ok field for a valid URL', async () => {
|
||||
const r = await fetch('api/bookmarks/fetch-meta?url=' + encodeURIComponent('https://example.com')).then(x => x.json());
|
||||
expect(r.ok).toBe(true);
|
||||
expect(typeof r.title).toBe('string');
|
||||
// favicon is either a data: URI or empty string
|
||||
expect(typeof r.favicon).toBe('string');
|
||||
});
|
||||
|
||||
it('returns 400 for missing URL', async () => {
|
||||
const r = await fetch('api/bookmarks/fetch-meta');
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: card links', () => {
|
||||
it('renders card:ID items with a card icon', async () => {
|
||||
let target, bm;
|
||||
try {
|
||||
target = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_target__', body: 'hello' }) }).then(r => r.json());
|
||||
const cols = [{ name: 'Links', items: [{ url: `card:${target.id}`, title: '__test_bm_target__', favicon: '' }] }];
|
||||
bm = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_cardlink__', body: bodyWith(cols) }) }).then(r => r.json());
|
||||
await window._reloadAll();
|
||||
await sleep(200);
|
||||
const el = document.querySelector(`.board-card[data-id="${bm.id}"]`);
|
||||
click(el);
|
||||
await sleep(150);
|
||||
const item = document.querySelector('.bm-item');
|
||||
expect(item).toExist();
|
||||
// Card links get a ph-card icon, not ph-globe
|
||||
const cardIcon = item.querySelector('.ph-card');
|
||||
expect(cardIcon).toExist();
|
||||
click($('bookmarks-close'));
|
||||
await sleep(80);
|
||||
} finally {
|
||||
for (const c of [target, bm]) {
|
||||
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel() });
|
||||
}
|
||||
await window._reloadAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmarks: column width persistence', () => {
|
||||
it('saves and restores columnWidth from card body', async () => {
|
||||
const cols = [{ name: 'W', items: [] }];
|
||||
let card;
|
||||
try {
|
||||
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_width__', body: bodyWith(cols, 300) }) }).then(r => r.json());
|
||||
await window._reloadAll();
|
||||
await sleep(200);
|
||||
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||
click(el);
|
||||
await sleep(150);
|
||||
const slider = $('#bookmarks-col-width');
|
||||
expect(slider).toExist();
|
||||
expect(parseInt(slider.value)).toBe(300);
|
||||
click($('bookmarks-close'));
|
||||
await sleep(80);
|
||||
} finally {
|
||||
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
|
||||
await window._reloadAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ const FILES = [
|
||||
'./theme.test.js',
|
||||
'./chat.test.js',
|
||||
'./shopping.test.js',
|
||||
'./bookmarks.test.js',
|
||||
];
|
||||
|
||||
// Await all imports so the runner can start in a deterministic state.
|
||||
|
||||
@@ -160,11 +160,11 @@ describe('Undo: Editor Save', () => {
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.due_date).toBe(null);
|
||||
|
||||
// Undo should restore the due date
|
||||
// Undo should restore the due date. The undo PATCH +
|
||||
// _reloadAll is async — poll state.cards instead of a fixed
|
||||
// sleep so slow environments don't flake.
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
card = await waitForStateCard(res.id, c => c.due_date === '2099-06-15', 3000);
|
||||
expect(card.due_date).toBe('2099-06-15');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user