Skip to content

Commit 90172f7

Browse files
Merge pull request #74 from JavaScriptSolidServer/issue-73-collection-pane
Collection pane: searchable bookmark feed (+ container-pane support)
2 parents fa24c44 + d7d9b98 commit 90172f7

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)