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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.22');
|
define('APP_VERSION', '1.9.23');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.22",
|
"version": "1.9.23",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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',
|
'./theme.test.js',
|
||||||
'./chat.test.js',
|
'./chat.test.js',
|
||||||
'./shopping.test.js',
|
'./shopping.test.js',
|
||||||
|
'./bookmarks.test.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Await all imports so the runner can start in a deterministic state.
|
// 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);
|
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);
|
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();
|
ctrlZ();
|
||||||
await sleep(400);
|
card = await waitForStateCard(res.id, c => c.due_date === '2099-06-15', 3000);
|
||||||
|
|
||||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
||||||
expect(card.due_date).toBe('2099-06-15');
|
expect(card.due_date).toBe('2099-06-15');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user