Skip to content

Commit d7d9b98

Browse files
feat(panes): searchable bookmark collection pane + container-pane support
Browser (data-browser-panes.js): panes are now offered containers as well as single resources. render() may be async and may return null to decline, in which case the browser falls through to the folder table / JSON (no regression for ordinary folders). h gains isContainer, path, children, and an async fetchResource(url) helper so a pane can render a collection. Collection pane (examples/panes/bookmark-collection.js): canHandles the /public/bookmark/ container and renders its bookmark:Bookmark members as a searchable feed — compact cards (favicon, title, host, summary snippet, tags), a live search box (inline oninput, no script tag), card body -> the bookmark's .jsonld detail page, corner arrow -> the original link. Returns null when the folder holds no bookmarks. No version bump: the browser change lands on gh-pages but won't reach the cdn until a future publish; pods can use it via a --mashlib-module override. Refs #73
1 parent fa24c44 commit d7d9b98

2 files changed

Lines changed: 138 additions & 15 deletions

File tree

data-browser-panes.js

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,18 @@
1010
// 3. No matching pane → pretty-printed JSON-LD (same as data-browser.js).
1111
//
1212
// Single file, no build, no framework. Local pane contract (ES module):
13-
// export default { canHandle(node, h) -> boolean, render(node, h) -> htmlString }
14-
// where h = { escape, prop, propAll, first, idOf, types, host, fmtDate, localName }.
15-
// (prop may return an array for multi-valued JSON-LD; use h.first for single values.)
13+
// export default {
14+
// canHandle(node, h) -> boolean,
15+
// render(node, h) -> htmlString | Promise<htmlString> // null/'' declines
16+
// }
17+
// Panes are offered BOTH single resources and containers (h.isContainer marks
18+
// which). A pane that returns null is skipped, so a container pane can decline a
19+
// folder it doesn't recognise and fall through to the folder table.
20+
// h = { escape, prop, propAll, first, idOf, types, host, fmtDate, localName,
21+
// isContainer, path, children, fetchResource(url) }
22+
// - prop may return an array for multi-valued JSON-LD; use h.first for one value.
23+
// - children: parsed [{ url, type, ... }] of a container's members.
24+
// - fetchResource(url): async, returns a child's primary node (for collections).
1625

1726
document.head.insertAdjacentHTML('beforeend', `<style>
1827
body{font:14px/1.55 system-ui,-apple-system,sans-serif;margin:0;color:#222;background:#f3eee5}
@@ -65,31 +74,49 @@ body{font:14px/1.55 system-ui,-apple-system,sans-serif;margin:0;color:#222;backg
6574
const items = doc ? parseContainer(doc, window.location.href) : null;
6675
const isContainer = Array.isArray(items);
6776

68-
const target = document.getElementById('mashlib');
69-
if (isContainer) {
70-
target.innerHTML = `<div class="db">${navHTML}<div class="db-card">${renderFolder(items, here)}</div></div>`;
71-
return;
72-
}
73-
74-
// Single resource: try a pod-local pane, else fall back to the JSON dump.
7577
const node = primaryNode(doc);
76-
const paneHTML = node ? await renderLocalPane(node) : null;
78+
const h = {
79+
escape, prop, propAll, first: firstVal, idOf, types: typesOf, host: hostOf,
80+
fmtDate: fmtDay, localName: localType,
81+
isContainer, path: window.location.pathname, children: items || [],
82+
// Fetch a child resource and return its primary node (for collection panes).
83+
fetchResource: async (u) => {
84+
try {
85+
const r = await fetch(u, { headers: { Accept: 'application/ld+json' } });
86+
if (!r.ok) return null;
87+
return primaryNode(await r.json());
88+
} catch (e) { return null; }
89+
}
90+
};
91+
92+
const target = document.getElementById('mashlib');
93+
// Panes get first refusal for BOTH single resources and containers. A pane
94+
// may return null/empty to decline (e.g. a collection pane on a folder it
95+
// doesn't recognise), in which case we fall through to folder table / JSON.
96+
const paneHTML = node ? await resolvePaneHTML(node, h) : null;
7797
if (paneHTML) {
7898
target.innerHTML = `<div class="db">${navHTML}<div class="db-pane-wrap">${paneHTML}</div>` +
7999
`<details class="db-src"><summary>Source</summary>${renderJson(doc)}</details></div>`;
100+
return;
101+
}
102+
if (isContainer) {
103+
target.innerHTML = `<div class="db">${navHTML}<div class="db-card">${renderFolder(items, here)}</div></div>`;
80104
} else {
81105
target.innerHTML = `<div class="db">${navHTML}${renderJson(doc)}</div>`;
82106
}
83107
}
84108

85109
// ---- local panes: discovered from /public/panes/, augmentable per-pod ----
86110

87-
async function renderLocalPane(node) {
111+
async function resolvePaneHTML(node, h) {
88112
const panes = await loadLocalPanes();
89-
if (!panes.length) return null;
90-
const h = { escape, prop, propAll, first: firstVal, idOf, types: typesOf, host: hostOf, fmtDate: fmtDay, localName: localType };
91113
for (const p of panes) {
92-
try { if (p.canHandle(node, h)) return p.render(node, h); } catch (e) { /* skip */ }
114+
try {
115+
if (p.canHandle(node, h)) {
116+
const out = await p.render(node, h);
117+
if (out) return out;
118+
}
119+
} catch (e) { /* skip */ }
93120
}
94121
return null;
95122
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Pod-local collection pane for the /public/bookmark/ container.
2+
// Renders every bookmark:Bookmark member as a searchable feed of compact cards.
3+
// Loaded by the `--browser panes` data browser from /public/panes/.
4+
//
5+
// canHandle matches the conventional bookmark container path; render fetches the
6+
// members and returns null if none are bookmarks (so the browser falls back to
7+
// the folder table). Search is a self-contained inline oninput handler — no
8+
// script tag (which wouldn't execute via innerHTML anyway).
9+
10+
export default {
11+
canHandle(node, h) {
12+
return h.isContainer && /\/bookmark\/?$/.test(h.path || '');
13+
},
14+
15+
async render(node, h) {
16+
const kids = (h.children || []).filter(it => it.type !== 'container' && it.url.endsWith('.jsonld'));
17+
const fetched = await Promise.all(kids.map(k =>
18+
h.fetchResource(k.url).then(n => ({ url: k.url, n })).catch(() => null)));
19+
20+
const items = [];
21+
for (const x of fetched) {
22+
if (!x || !x.n || !h.types(x.n).includes('Bookmark')) continue;
23+
const recalls = h.idOf(h.prop(x.n, 'recalls'));
24+
const title = h.first(h.prop(x.n, 'title')) || recalls || 'Untitled bookmark';
25+
const lead = String(h.first(h.prop(x.n, 'description')) || '').split(/\n\s*\n/)[0].trim();
26+
const snippet = lead.length > 190 ? lead.slice(0, 187) + '…' : lead;
27+
const tags = h.propAll(x.n, 'keywords').map(t => h.first(t)).filter(Boolean);
28+
const host = h.host(recalls);
29+
const created = h.first(h.prop(x.n, 'created')) || '';
30+
const q = [title, lead, tags.join(' '), host].join(' ').toLowerCase();
31+
items.push({ recalls, podUrl: x.url, title, snippet, tags, host, created, q });
32+
}
33+
if (!items.length) return null; // not a bookmark collection → fall back to folder
34+
items.sort((a, b) => (b.created || '').localeCompare(a.created || ''));
35+
36+
const card = it => {
37+
const fav = it.recalls ? h.escape(new URL('/favicon.ico', it.recalls).href) : '';
38+
const chips = it.tags.slice(0, 5).map(t => `<span class="bmc-tag">${h.escape(t)}</span>`).join('');
39+
// Card body → the bookmark's .jsonld detail page (full summary card).
40+
// Corner ↗ → the original external link.
41+
return `<div class="bmc-item" data-q="${h.escape(it.q)}">
42+
<a class="bmc-main" href="${h.escape(it.podUrl)}">
43+
<img class="bmc-fav" src="${fav}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{textContent:'🔖',style:'font-size:22px;line-height:34px;width:34px;text-align:center'}))">
44+
<div class="bmc-body">
45+
<div class="bmc-title">${h.escape(it.title)}</div>
46+
<div class="bmc-host">${h.escape(it.host)}</div>
47+
${it.snippet ? `<div class="bmc-snippet">${h.escape(it.snippet)}</div>` : ''}
48+
${chips ? `<div class="bmc-tags">${chips}</div>` : ''}
49+
</div>
50+
</a>
51+
<a class="bmc-ext" href="${h.escape(it.recalls)}" target="_blank" rel="noopener" title="Open original ↗" aria-label="Open original">↗</a>
52+
</div>`;
53+
};
54+
55+
const filter = "var r=this.closest('.bmc'),q=this.value.toLowerCase().trim(),n=0;" +
56+
"r.querySelectorAll('.bmc-item').forEach(function(el){var m=el.getAttribute('data-q').indexOf(q)>-1;el.style.display=m?'':'none';if(m)n++;});" +
57+
"r.querySelector('.bmc-count').textContent=n;" +
58+
"r.querySelector('.bmc-empty').style.display=n?'none':'block';";
59+
60+
return `<style>
61+
.bmc{max-width:720px;margin:0 auto;padding:8px 0 28px;font-family:Inter,-apple-system,system-ui,sans-serif;}
62+
.bmc-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:20px;}
63+
.bmc-eyebrow{font-family:Georgia,serif;font-style:italic;font-size:13px;color:#a1a1aa;}
64+
.bmc-title-main{font-size:24px;font-weight:650;color:#18181b;margin-top:5px;}
65+
.bmc-count{font-size:15px;color:#a1a1aa;font-weight:500;margin-left:5px;}
66+
.bmc-search{border:1px solid rgba(24,24,27,.12);border-radius:10px;padding:9px 13px;font-size:14px;outline:none;width:210px;max-width:45vw;background:#fff;font-family:inherit;transition:border-color .15s,box-shadow .15s;}
67+
.bmc-search:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.12);}
68+
.bmc-list{display:flex;flex-direction:column;gap:12px;}
69+
.bmc-item{position:relative;background:#fff;border:1px solid rgba(24,24,27,.07);border-radius:14px;box-shadow:0 1px 2px rgba(24,24,27,.04);transition:box-shadow .15s,border-color .15s;}
70+
.bmc-item:hover{box-shadow:0 4px 18px rgba(24,24,27,.09);border-color:rgba(124,58,237,.25);}
71+
.bmc-main{display:flex;gap:14px;align-items:flex-start;text-decoration:none;color:inherit;padding:18px 52px 18px 20px;}
72+
.bmc-main:active{transform:translateY(1px);}
73+
.bmc-ext{position:absolute;top:13px;right:13px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:8px;color:#b4b4bb;text-decoration:none;font-size:16px;line-height:1;transition:background .15s,color .15s;}
74+
.bmc-ext:hover{background:rgba(124,58,237,.1);color:#7c3aed;}
75+
.bmc-fav{width:34px;height:34px;border-radius:8px;flex:0 0 auto;background:rgba(127,127,127,.06);object-fit:contain;margin-top:2px;}
76+
.bmc-body{min-width:0;flex:1;}
77+
.bmc-title{font-size:16px;font-weight:600;color:#18181b;line-height:1.35;overflow-wrap:anywhere;}
78+
.bmc-host{font-size:12px;color:#7c3aed;font-family:ui-monospace,SFMono-Regular,monospace;margin-top:4px;}
79+
.bmc-snippet{font-size:13.5px;line-height:1.6;color:#52525b;margin-top:9px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
80+
.bmc-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:11px;}
81+
.bmc-tag{font-size:11px;color:#6d28d9;background:rgba(124,58,237,.07);border:1px solid rgba(124,58,237,.12);padding:2px 9px;border-radius:999px;}
82+
.bmc-empty{text-align:center;color:#a1a1aa;padding:28px;font-size:14px;}
83+
</style>
84+
<div class="bmc">
85+
<div class="bmc-head">
86+
<div>
87+
<div class="bmc-eyebrow">Collection</div>
88+
<div class="bmc-title-main">Bookmarks <span class="bmc-count">${items.length}</span></div>
89+
</div>
90+
<input class="bmc-search" type="search" placeholder="Search title, summary, tags…" oninput="${filter}">
91+
</div>
92+
<div class="bmc-list">${items.map(card).join('')}</div>
93+
<div class="bmc-empty" style="display:none">No matches.</div>
94+
</div>`;
95+
}
96+
};

0 commit comments

Comments
 (0)