/** * — Canvas shape that renders a live feed from another layer. * * Bridges layers by pulling data from a source layer's module API endpoint * and rendering it as a live, updating feed within the current canvas. * * Attributes: * source-layer — source layer ID * source-module — source module ID (e.g. "notes", "funds", "vote") * feed-id — which feed to pull (e.g. "recent-notes", "proposals") * flow-kind — flow type for visual styling ("economic", "trust", "data", etc.) * feed-filter — optional JSON filter string * max-items — max items to display (default 10) * refresh-interval — auto-refresh ms (default 30000, 0 = manual only) * * The shape auto-fetches from /{space}/{source-module}/api/{feed-endpoint} * and renders results as a scrollable card list. */ import { FolkShape } from "./folk-shape"; import { FLOW_COLORS, FLOW_LABELS } from "./layer-types"; import type { FlowKind } from "./layer-types"; export class FolkFeed extends FolkShape { static tagName = "folk-feed"; #feedData: any[] = []; #loading = false; #error: string | null = null; #refreshTimer: ReturnType | null = null; #inner: HTMLElement | null = null; #editingIndex: number | null = null; static get observedAttributes() { return [ ...FolkShape.observedAttributes, "source-layer", "source-module", "feed-id", "flow-kind", "feed-filter", "max-items", "refresh-interval", ]; } get sourceLayer(): string { return this.getAttribute("source-layer") || ""; } set sourceLayer(v: string) { this.setAttribute("source-layer", v); } get sourceModule(): string { return this.getAttribute("source-module") || ""; } set sourceModule(v: string) { this.setAttribute("source-module", v); } get feedId(): string { return this.getAttribute("feed-id") || ""; } set feedId(v: string) { this.setAttribute("feed-id", v); } get flowKind(): FlowKind { return (this.getAttribute("flow-kind") as FlowKind) || "data"; } set flowKind(v: FlowKind) { this.setAttribute("flow-kind", v); } get feedFilter(): string { return this.getAttribute("feed-filter") || ""; } set feedFilter(v: string) { this.setAttribute("feed-filter", v); } get maxItems(): number { return parseInt(this.getAttribute("max-items") || "10", 10); } set maxItems(v: number) { this.setAttribute("max-items", String(v)); } get refreshInterval(): number { return parseInt(this.getAttribute("refresh-interval") || "30000", 10); } set refreshInterval(v: number) { this.setAttribute("refresh-interval", String(v)); } connectedCallback() { super.connectedCallback(); this.#buildUI(); this.#fetchFeed(); this.#startAutoRefresh(); } disconnectedCallback() { super.disconnectedCallback(); this.#stopAutoRefresh(); } attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) { super.attributeChangedCallback(name, oldVal, newVal); if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) { this.#fetchFeed(); } if (name === "refresh-interval") { this.#stopAutoRefresh(); this.#startAutoRefresh(); } if (name === "flow-kind") { this.#updateHeader(); } } // ── Build the inner UI ── #buildUI() { if (this.#inner) return; this.#inner = document.createElement("div"); this.#inner.className = "folk-feed-inner"; this.#inner.innerHTML = `
`; const style = document.createElement("style"); style.textContent = FEED_STYLES; this.#inner.prepend(style); this.appendChild(this.#inner); // Refresh button this.#inner.querySelector(".feed-refresh")?.addEventListener("click", (e) => { e.stopPropagation(); this.#fetchFeed(); }); // Navigate to source layer this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.sourceModule) { const space = this.#getSpaceSlug(); window.location.href = `/${space}/${this.sourceModule}`; } }); this.#updateHeader(); } #updateHeader() { if (!this.#inner) return; const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; const label = FLOW_LABELS[this.flowKind] || "Feed"; const dot = this.#inner.querySelector(".feed-kind-dot"); const title = this.#inner.querySelector(".feed-title"); if (dot) dot.style.background = color; if (title) title.textContent = `${this.sourceModule} / ${this.feedId || label}`; } // ── Fetch feed data ── async #fetchFeed() { if (!this.sourceModule) return; if (this.#loading) return; this.#loading = true; this.#updateStatus("loading"); try { // Construct feed URL based on feed ID const space = this.#getSpaceSlug(); const feedEndpoint = this.#getFeedEndpoint(); const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // Normalize: extract the array from common response shapes if (Array.isArray(data)) { this.#feedData = data.slice(0, this.maxItems); } else if (data.notes) { this.#feedData = data.notes.slice(0, this.maxItems); } else if (data.notebooks) { this.#feedData = data.notebooks.slice(0, this.maxItems); } else if (data.proposals) { this.#feedData = data.proposals.slice(0, this.maxItems); } else if (data.tasks) { this.#feedData = data.tasks.slice(0, this.maxItems); } else if (data.nodes) { this.#feedData = data.nodes.slice(0, this.maxItems); } else if (data.flows) { this.#feedData = data.flows.slice(0, this.maxItems); } else { // Try to use the data as-is if it has array-like fields const firstArray = Object.values(data).find(v => Array.isArray(v)); this.#feedData = firstArray ? (firstArray as any[]).slice(0, this.maxItems) : [data]; } this.#error = null; this.#renderItems(); this.#updateStatus("ok"); } catch (err: any) { this.#error = err.message || "Failed to fetch"; this.#updateStatus("error"); } finally { this.#loading = false; } } #getFeedEndpoint(): string { // Map feed IDs to actual API endpoints const FEED_ENDPOINTS: Record> = { notes: { "notes-by-tag": "notes", "recent-notes": "notes", default: "notes", }, funds: { "treasury-flows": "flows", "transactions": "flows", default: "flows", }, vote: { proposals: "proposals", decisions: "proposals?status=PASSED,FAILED", default: "proposals", }, choices: { "poll-results": "choices", default: "choices", }, wallet: { balances: "safe/detect", transfers: "safe/detect", default: "safe/detect", }, data: { analytics: "stats", "active-users": "active", default: "stats", }, work: { "task-activity": "spaces", "board-summary": "spaces", default: "spaces", }, network: { "trust-graph": "graph", connections: "people", default: "graph", }, trips: { "trip-expenses": "trips", itinerary: "trips", default: "trips", }, }; const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule]; if (!moduleEndpoints) return this.feedId || "info"; return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId; } #getSpaceSlug(): string { // Try to get from URL const parts = window.location.pathname.split("/").filter(Boolean); return parts[0] || "demo"; } // ── Render feed items ── #renderItems() { const container = this.#inner?.querySelector(".feed-items"); if (!container) return; const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; if (this.#feedData.length === 0) { container.innerHTML = `
No data
`; return; } container.innerHTML = this.#feedData.map((item, i) => { const title = item.title || item.name || item.label || item.id || `Item ${i + 1}`; const subtitle = item.description || item.content_plain?.slice(0, 80) || item.status || item.type || ""; const badge = item.status || item.kind || item.type || ""; const editable = this.#isEditable(item); return `
${this.#escapeHtml(String(title))}
${subtitle ? `
${this.#escapeHtml(String(subtitle).slice(0, 100))}
` : ""}
${editable ? `` : ""} ${badge ? `
${this.#escapeHtml(String(badge))}
` : ""}
`; }).join(""); // Attach item click and edit events container.querySelectorAll(".feed-item").forEach(el => { // Double-click to navigate to source el.addEventListener("dblclick", (e) => { e.stopPropagation(); const idx = parseInt(el.dataset.index || "0", 10); const item = this.#feedData[idx]; if (item) this.#navigateToItem(item); }); }); container.querySelectorAll(".feed-item-edit").forEach(btn => { btn.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.edit || "0", 10); this.#openEditOverlay(idx); }); }); } /** Check if an item supports write-back */ #isEditable(item: any): boolean { // Items with an ID from modules that support PUT/PATCH are editable if (!item.id) return false; const editableModules = ["notes", "work", "vote", "trips"]; return editableModules.includes(this.sourceModule); } /** Navigate to the source item in its module */ #navigateToItem(item: any) { const space = this.#getSpaceSlug(); const mod = this.sourceModule; // Build a deep link to the item in its source module // Emit an event so the canvas/shell can handle it this.dispatchEvent(new CustomEvent("feed-navigate", { bubbles: true, detail: { sourceModule: mod, itemId: item.id, item, url: `/${space}/${mod}`, } })); } // ── Edit overlay (bidirectional write-back) ── #openEditOverlay(index: number) { const overlay = this.#inner?.querySelector(".feed-edit-overlay"); if (!overlay) return; const item = this.#feedData[index]; if (!item) return; this.#editingIndex = index; const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; // Build edit fields based on item properties const editableFields = this.#getEditableFields(item); overlay.innerHTML = `
Edit: ${this.#escapeHtml(item.title || item.name || "Item")}
${editableFields.map(f => ` `).join("")}
`; overlay.classList.add("open"); // Events overlay.querySelector(".edit-close")?.addEventListener("click", () => this.#closeEditOverlay()); overlay.querySelector(".edit-cancel")?.addEventListener("click", () => this.#closeEditOverlay()); overlay.querySelector(".edit-save")?.addEventListener("click", () => this.#saveEdit()); } #closeEditOverlay() { const overlay = this.#inner?.querySelector(".feed-edit-overlay"); if (overlay) { overlay.classList.remove("open"); overlay.innerHTML = ""; } this.#editingIndex = null; } async #saveEdit() { if (this.#editingIndex === null) return; const item = this.#feedData[this.#editingIndex]; if (!item?.id) return; const overlay = this.#inner?.querySelector(".feed-edit-overlay"); if (!overlay) return; // Collect edited values const updates: Record = {}; overlay.querySelectorAll(".edit-input").forEach(el => { const field = el.dataset.field; if (field && el.value !== String(item[field] ?? "")) { updates[field] = el.value; } }); if (Object.keys(updates).length === 0) { this.#closeEditOverlay(); return; } // Write back to source module API try { const space = this.#getSpaceSlug(); const endpoint = this.#getWriteBackEndpoint(item); const url = `/${space}/${this.sourceModule}/api/${endpoint}`; const method = this.#getWriteBackMethod(); const token = this.#getAuthToken(); const res = await fetch(url, { method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(updates), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed" })); throw new Error(err.error || `HTTP ${res.status}`); } // Update local data Object.assign(item, updates); this.#renderItems(); this.#closeEditOverlay(); // Emit event for flow tracking this.dispatchEvent(new CustomEvent("feed-writeback", { bubbles: true, detail: { sourceModule: this.sourceModule, itemId: item.id, updates, flowKind: this.flowKind, } })); } catch (err: any) { // Show error in overlay const actions = overlay.querySelector(".edit-actions"); if (actions) { const existing = actions.querySelector(".edit-error"); if (existing) existing.remove(); const errorEl = document.createElement("div"); errorEl.className = "edit-error"; errorEl.textContent = err.message; actions.prepend(errorEl); } } } #getEditableFields(item: any): { key: string; label: string; value: string; type: string; options?: string[] }[] { const fields: { key: string; label: string; value: string; type: string; options?: string[] }[] = []; // Module-specific editable fields switch (this.sourceModule) { case "notes": if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); if (item.content !== undefined) fields.push({ key: "content", label: "Content", value: item.content || "", type: "textarea" }); break; case "work": if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); if (item.status !== undefined) fields.push({ key: "status", label: "Status", value: item.status, type: "select", options: ["TODO", "IN_PROGRESS", "REVIEW", "DONE"], }); if (item.priority !== undefined) fields.push({ key: "priority", label: "Priority", value: item.priority || "MEDIUM", type: "select", options: ["LOW", "MEDIUM", "HIGH", "URGENT"], }); break; case "vote": if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); break; case "trips": if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); break; default: // Generic: expose title and description if present if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); } return fields; } #getWriteBackEndpoint(item: any): string { switch (this.sourceModule) { case "notes": return `notes/${item.id}`; case "work": return `tasks/${item.id}`; case "vote": return `proposals/${item.id}`; case "trips": return `trips/${item.id}`; default: return `${item.id}`; } } #getWriteBackMethod(): string { switch (this.sourceModule) { case "work": return "PATCH"; default: return "PUT"; } } #getAuthToken(): string | null { // Try to get token from EncryptID (stored in localStorage by rstack-identity) try { return localStorage.getItem("encryptid-token") || null; } catch { return null; } } #updateStatus(state: "loading" | "ok" | "error") { const el = this.#inner?.querySelector(".feed-status"); if (!el) return; if (state === "loading") { el.textContent = "Loading..."; el.style.color = "#94a3b8"; } else if (state === "error") { el.textContent = this.#error || "Error"; el.style.color = "#ef4444"; } else { el.textContent = `${this.#feedData.length} items`; el.style.color = FLOW_COLORS[this.flowKind] || "#94a3b8"; } } // ── Auto-refresh ── #startAutoRefresh() { if (this.refreshInterval > 0) { this.#refreshTimer = setInterval(() => this.#fetchFeed(), this.refreshInterval); } } #stopAutoRefresh() { if (this.#refreshTimer) { clearInterval(this.#refreshTimer); this.#refreshTimer = null; } } // ── Serialization ── toJSON() { return { ...super.toJSON(), type: "folk-feed", sourceLayer: this.sourceLayer, sourceModule: this.sourceModule, feedId: this.feedId, flowKind: this.flowKind, feedFilter: this.feedFilter, maxItems: this.maxItems, refreshInterval: this.refreshInterval, }; } #escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } static define(tag = "folk-feed") { if (!customElements.get(tag)) customElements.define(tag, FolkFeed); } } // ── Styles ── const FEED_STYLES = ` .folk-feed-inner { display: flex; flex-direction: column; height: 100%; border-radius: 8px; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: rgba(15, 23, 42, 0.9); border: 1px solid rgba(255,255,255,0.08); } .feed-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; } .feed-kind-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .feed-title { flex: 1; font-size: 0.75rem; font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .feed-refresh { width: 22px; height: 22px; border: none; border-radius: 4px; background: transparent; color: #64748b; font-size: 0.85rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: color 0.15s, background 0.15s; } .feed-refresh:hover { color: #e2e8f0; background: rgba(255,255,255,0.06); } .feed-items { flex: 1; overflow-y: auto; padding: 4px 0; scrollbar-width: thin; scrollbar-color: rgba(148,163,184,0.2) transparent; } .feed-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 12px; transition: background 0.12s; cursor: default; } .feed-item:hover { background: rgba(255,255,255,0.03); } .feed-item-line { width: 3px; min-height: 24px; border-radius: 2px; flex-shrink: 0; margin-top: 2px; opacity: 0.6; } .feed-item-content { flex: 1; min-width: 0; } .feed-item-title { font-size: 0.75rem; font-weight: 500; color: #e2e8f0; line-height: 1.3; } .feed-item-subtitle { font-size: 0.65rem; color: #64748b; margin-top: 2px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .feed-item-badge { font-size: 0.55rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; padding: 2px 4px; border-radius: 3px; background: rgba(255,255,255,0.04); flex-shrink: 0; margin-top: 2px; } .feed-navigate { width: 22px; height: 22px; border: none; border-radius: 4px; background: transparent; color: #64748b; font-size: 0.75rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: color 0.15s, background 0.15s; } .feed-navigate:hover { color: #22d3ee; background: rgba(34,211,238,0.1); } .feed-item--editable { cursor: pointer; } .feed-item--editable:hover { background: rgba(255,255,255,0.05); } .feed-item-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .feed-item-edit { width: 20px; height: 20px; border: none; border-radius: 3px; background: transparent; color: #475569; font-size: 0.7rem; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s, color 0.15s, background 0.15s; } .feed-item:hover .feed-item-edit { opacity: 0.7; } .feed-item-edit:hover { opacity: 1 !important; color: #22d3ee; background: rgba(34,211,238,0.1); } .feed-empty { padding: 20px; text-align: center; font-size: 0.75rem; color: #475569; } .feed-status { padding: 4px 12px; font-size: 0.6rem; text-align: right; border-top: 1px solid rgba(255,255,255,0.04); flex-shrink: 0; } /* ── Edit overlay ── */ .feed-edit-overlay { display: none; position: absolute; inset: 0; background: rgba(15, 23, 42, 0.95); border-radius: 8px; z-index: 10; overflow: auto; } .feed-edit-overlay.open { display: flex; } .edit-panel { display: flex; flex-direction: column; width: 100%; padding: 12px; gap: 10px; } .edit-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.75rem; font-weight: 600; } .edit-close { width: 22px; height: 22px; border: none; border-radius: 4px; background: transparent; color: #94a3b8; font-size: 1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; } .edit-close:hover { color: #ef4444; background: rgba(239,68,68,0.1); } .edit-fields { display: flex; flex-direction: column; gap: 8px; flex: 1; } .edit-field { display: flex; flex-direction: column; gap: 3px; } .edit-label { font-size: 0.6rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .edit-input { padding: 6px 8px; border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; background: rgba(255,255,255,0.04); color: #e2e8f0; font-size: 0.75rem; font-family: inherit; outline: none; transition: border-color 0.15s; resize: vertical; } .edit-input:focus { border-color: rgba(34,211,238,0.4); } select.edit-input { cursor: pointer; } .edit-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; } .edit-cancel, .edit-save { padding: 5px 12px; border-radius: 5px; font-size: 0.7rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } .edit-cancel { border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; } .edit-cancel:hover { color: #e2e8f0; } .edit-save { border: 1px solid; } .edit-save:hover { opacity: 0.8; } .edit-error { font-size: 0.65rem; color: #ef4444; flex: 1; } `;