Skip to content

Commit fc4e567

Browse files
panes: add generic CollectionPane + tighten ctx for recursion
CollectionPane takes {items, layout, empty, onSelect} and renders each item into its own slot via ctx.resolvePane — so urn:solid:view, user pins, and registration order all flow through identically to single- subject rendering. Async per-slot with fallback placeholders. The adapter now forwards rawData to canHandle (3rd arg) and ctx to render (5th arg) — both hub extensions over SLIP-48's spec, ignored by panes that don't need them. ctx now carries findPane, resolvePane, and authFetch so any pane can recurse without knowing the host. Migrate Contacts as a proof: ~25 lines of hand-rolled iterate-and- findFor loop become ~10 lines that hand items to CollectionPane. Trade-off: lost the placeholder-then-upgrade UX (now one-pass after profiles resolve in parallel).
1 parent e5355cc commit fc4e567

4 files changed

Lines changed: 145 additions & 24 deletions

File tree

‎src/app.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as Activity from "./apps/activity.js";
1818
import * as Settings from "./apps/settings.js";
1919

2020
import { register as registerApp, list as listApps, find as findApp, loadAllExternal } from "./apps.js";
21+
import { findFor as findPane, resolveFor as resolvePane } from "./panes.js";
2122

2223
// Register built-in panes. External panes can register themselves via
2324
// import('./panes.js').then(m => m.register(myPane)).
@@ -29,6 +30,7 @@ import * as EventPane from "./panes/event.js";
2930
import * as PhotoPane from "./panes/photo.js";
3031
import * as PersonPane from "./panes/person.js";
3132
import * as FilePane from "./panes/file.js";
33+
import * as CollectionPane from "./panes/collection.js";
3234
import { register as registerPane, adapt } from "./panes.js";
3335

3436
// Tracker pane priority:
@@ -46,6 +48,7 @@ registerPane(adapt(EventPane, "hub-pod/event"));
4648
registerPane(adapt(PhotoPane, "hub-pod/photo"));
4749
registerPane(adapt(PersonPane, "hub-pod/person"));
4850
registerPane(adapt(FilePane, "hub-pod/file"));
51+
registerPane(adapt(CollectionPane, "hub-pod/collection"));
4952

5053
// Built-in apps. Order here = rail order. External apps load via
5154
// loadAllExternal() at boot and append to the registry.
@@ -60,6 +63,15 @@ const state = {
6063
const ctx = {
6164
get auth() { return getAuth(); },
6265
switchApp,
66+
// Pane recursion: collection-shaped panes delegate per-item rendering
67+
// back through the host so the same urn:solid:view → user-pin → registry
68+
// cascade is honoured for children.
69+
findPane,
70+
resolvePane,
71+
// Authenticated fetch — DPoP-signed for Solid sessions, same as
72+
// pod.js uses internally. Panes that need their own GET/PUT calls
73+
// should prefer pod.js helpers, but this is the escape hatch.
74+
fetch: (...args) => (window.xlogin?.authFetch || fetch)(...args),
6375
};
6476

6577
// ---- Rail ----------------------------------------------------------------

‎src/apps/contacts.js‎

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -69,29 +69,25 @@ export async function render(container, ctx) {
6969
return;
7070
}
7171

72-
page.innerHTML = `<div class="contacts-grid" id="contacts-grid"></div>`;
72+
page.innerHTML = `<div id="contacts-grid" style="padding:22px 24px"></div>`;
7373
const grid = $("#contacts-grid");
7474

75-
// Render placeholder cards immediately; resolve profiles in parallel
76-
webIds.forEach(async (wid, i) => {
77-
const slot = document.createElement("div");
78-
grid.appendChild(slot);
79-
// Initial placeholder via the pane (no profile yet)
80-
const placeholder = { url: wid, doc: { mode: "card" }, forClass: FOAF_PERSON };
81-
const pane = findFor(placeholder);
82-
if (!pane) {
83-
slot.outerHTML = `<div class="contact-card"><div class="ava">?</div><div class="name">No PersonPane</div><div class="webid">${escape(wid)}</div></div>`;
84-
return;
85-
}
86-
pane.render(placeholder, slot, ctx);
87-
// Then upgrade with the real profile
88-
try {
89-
const profile = await fetchWebIdProfile(wid);
90-
pane.render({ url: wid, doc: { profile, mode: "card" }, forClass: FOAF_PERSON }, slot, ctx);
91-
} catch {
92-
// leave placeholder
93-
}
94-
});
75+
// Resolve every WebID's profile in parallel; failed ones render with
76+
// null profile (PersonPane card-mode shows "Loading…" placeholder).
77+
const profiles = await Promise.all(
78+
webIds.map(wid => fetchWebIdProfile(wid).catch(() => null))
79+
);
80+
const items = webIds.map((wid, i) => ({
81+
url: wid,
82+
doc: { profile: profiles[i], mode: "card" },
83+
forClass: FOAF_PERSON,
84+
}));
85+
86+
// Hand the items to the generic CollectionPane — it iterates and
87+
// delegates each card back through ctx.resolvePane to PersonPane.
88+
const collInput = { doc: { items, layout: "grid" } };
89+
const coll = findFor(collInput);
90+
if (coll) await coll.render(collInput, grid, ctx);
9591
}
9692

9793
function loginPrompt() {

‎src/panes.js‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,12 @@ export function adapt(obj, idOrUrl) {
160160
canHandle(input) {
161161
const subject = makeSubject(input);
162162
const store = makeStore(input);
163-
try { return obj.canHandle(subject, store); } catch { return false; }
163+
// 3rd arg (rawData) is a hub extension over SLIP-48's 2-arg canHandle —
164+
// collection-shape panes need it to match on `items` etc. Other panes
165+
// ignore the extra arg.
166+
try { return obj.canHandle(subject, store, input?.doc || null); } catch { return false; }
164167
},
165-
async render(input, container, _ctx) {
168+
async render(input, container, ctx) {
166169
// Bridge SLIP-48 CustomEvents → hub's input.onChange / input.onDelete /
167170
// input.onOpen callbacks. Panes that emit pane:change / pane:delete /
168171
// pane:open on their container get their callbacks wired up automatically.
@@ -174,7 +177,10 @@ export function adapt(obj, idOrUrl) {
174177
container.addEventListener("pane:open", onOpen);
175178
const subject = makeSubject(input);
176179
const store = makeStore(input);
177-
return obj.render(subject, store, container, input?.doc || null);
180+
// ctx is a hub extension over SLIP-48's 4-arg render — collection-shape
181+
// panes use ctx.resolvePane to recurse into child panes. Other panes
182+
// ignore the extra arg.
183+
return obj.render(subject, store, container, input?.doc || null, ctx);
178184
},
179185
};
180186
}

‎src/panes/collection.js‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* collection.js — generic list-of-subjects pane.
3+
*
4+
* Takes an array of items (each `{url, doc?, forClass?, view?}`) and
5+
* renders each into its own slot using whichever per-item pane the
6+
* host's registry returns. The collection pane itself draws no leaf
7+
* data — it's pure orchestration over child panes.
8+
*
9+
* Input (rawData):
10+
* {
11+
* items: Array<{url, doc?, forClass?, view?}>,
12+
* layout?: "grid" | "list" // default "grid"
13+
* empty?: { title?, body? } // shown when items.length === 0
14+
* onSelect?: (item) => void // optional click forwarding
15+
* }
16+
*
17+
* Hosts invoke explicitly: `pane.render({items}, container, ctx)`.
18+
* Per-slot rendering uses `ctx.resolvePane(input)` so urn:solid:view,
19+
* user pins, and registration order all flow through identically to
20+
* single-subject rendering. If any slot's pane lookup fails, that
21+
* slot gets a placeholder with the URL — the rest still render.
22+
*/
23+
24+
import { ICON, escape } from "../ui.js";
25+
26+
export const label = "Collection";
27+
export const icon = "🗂";
28+
export const meta = {
29+
id: "hub-pod/collection",
30+
name: "Generic collection (grid/list)",
31+
};
32+
33+
export function canHandle(_subject, _store, rawData) {
34+
return Array.isArray(rawData?.items);
35+
}
36+
37+
export async function render(_subject, _store, container, rawData, ctx) {
38+
const items = rawData?.items || [];
39+
const layout = rawData?.layout || "grid";
40+
const empty = rawData?.empty;
41+
const onSelect = rawData?.onSelect;
42+
43+
injectStyles();
44+
45+
if (!items.length) {
46+
container.innerHTML = `<div class="coll-empty">
47+
<div class="coll-empty-title">${escape(empty?.title || "Nothing here yet")}</div>
48+
${empty?.body ? `<div class="coll-empty-body">${escape(empty.body)}</div>` : ""}
49+
</div>`;
50+
return;
51+
}
52+
53+
const grid = document.createElement("div");
54+
grid.className = `coll coll-${layout}`;
55+
container.appendChild(grid);
56+
57+
// Resolve panes in parallel — most slots come from the same class so
58+
// they hit the registry cache. Failures fall back to a placeholder.
59+
await Promise.all(items.map(async (item) => {
60+
const slot = document.createElement("div");
61+
slot.className = "coll-slot";
62+
if (onSelect) {
63+
slot.style.cursor = "pointer";
64+
slot.addEventListener("click", () => onSelect(item));
65+
}
66+
grid.appendChild(slot);
67+
try {
68+
const pane = ctx?.resolvePane
69+
? await ctx.resolvePane(item)
70+
: (ctx?.findPane ? ctx.findPane(item) : null);
71+
if (pane) {
72+
await pane.render(item, slot, ctx);
73+
} else {
74+
slot.innerHTML = `<div class="coll-fallback">
75+
<div class="coll-fallback-url">${escape(item.url || "(no url)")}</div>
76+
<div class="coll-fallback-hint">No pane registered${item.forClass ? ` for ${escape(item.forClass)}` : ""}.</div>
77+
</div>`;
78+
}
79+
} catch (e) {
80+
slot.innerHTML = `<div class="coll-fallback err">
81+
<div class="coll-fallback-url">${escape(item.url || "(no url)")}</div>
82+
<div class="coll-fallback-hint">Pane failed: ${escape(e.message)}</div>
83+
</div>`;
84+
}
85+
}));
86+
}
87+
88+
function injectStyles() {
89+
if (document.getElementById("coll-pane-css")) return;
90+
const s = document.createElement("style");
91+
s.id = "coll-pane-css";
92+
s.textContent = `
93+
.coll-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
94+
.coll-list { display: flex; flex-direction: column; gap: 6px; }
95+
.coll-slot { min-width: 0; }
96+
.coll-fallback { padding: 12px 14px; border: 1px dashed var(--line); border-radius: 8px; color: var(--text-dim); }
97+
.coll-fallback.err { border-color: var(--danger); color: var(--danger); }
98+
.coll-fallback-url { font-family: var(--mono); font-size: 12px; word-break: break-all; }
99+
.coll-fallback-hint { font-size: 12px; color: var(--text-faint); margin-top: 4px; }
100+
.coll-empty { padding: 32px 24px; text-align: center; color: var(--text-faint); }
101+
.coll-empty-title { font-size: 15px; color: var(--text); margin-bottom: 4px; }
102+
.coll-empty-body { font-size: 13px; }
103+
`;
104+
document.head.appendChild(s);
105+
}
106+
107+
export default { label, icon, canHandle, render };

0 commit comments

Comments
 (0)