rspace-online/lib/folk-feed.ts

888 lines
24 KiB
TypeScript

/**
* <folk-feed> — 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<typeof setInterval> | 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 = `
<div class="feed-header">
<div class="feed-kind-dot"></div>
<div class="feed-title"></div>
<button class="feed-navigate" title="Go to source layer">↗</button>
<button class="feed-refresh" title="Refresh">↻</button>
</div>
<div class="feed-items"></div>
<div class="feed-edit-overlay"></div>
<div class="feed-status"></div>
`;
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<HTMLElement>(".feed-kind-dot");
const title = this.#inner.querySelector<HTMLElement>(".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<string, Record<string, string>> = {
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 = `<div class="feed-empty">No data</div>`;
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 `
<div class="feed-item ${editable ? "feed-item--editable" : ""}" data-index="${i}" data-item-id="${item.id || ""}">
<div class="feed-item-line" style="background:${color}"></div>
<div class="feed-item-content">
<div class="feed-item-title">${this.#escapeHtml(String(title))}</div>
${subtitle ? `<div class="feed-item-subtitle">${this.#escapeHtml(String(subtitle).slice(0, 100))}</div>` : ""}
</div>
<div class="feed-item-actions">
${editable ? `<button class="feed-item-edit" data-edit="${i}" title="Edit">✎</button>` : ""}
${badge ? `<div class="feed-item-badge" style="color:${color}">${this.#escapeHtml(String(badge))}</div>` : ""}
</div>
</div>
`;
}).join("");
// Attach item click and edit events
container.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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 = `
<div class="edit-panel">
<div class="edit-header">
<span style="color:${color}">Edit: ${this.#escapeHtml(item.title || item.name || "Item")}</span>
<button class="edit-close">&times;</button>
</div>
<div class="edit-fields">
${editableFields.map(f => `
<label class="edit-field">
<span class="edit-label">${f.label}</span>
${f.type === "textarea"
? `<textarea class="edit-input" data-field="${f.key}" rows="3">${this.#escapeHtml(String(f.value))}</textarea>`
: f.type === "select"
? `<select class="edit-input" data-field="${f.key}">
${f.options!.map(o => `<option value="${o}" ${o === f.value ? "selected" : ""}>${o}</option>`).join("")}
</select>`
: `<input class="edit-input" type="text" data-field="${f.key}" value="${this.#escapeHtml(String(f.value))}" />`
}
</label>
`).join("")}
</div>
<div class="edit-actions">
<button class="edit-cancel">Cancel</button>
<button class="edit-save" style="background:${color}20; color:${color}; border-color:${color}40">Save & Push</button>
</div>
</div>
`;
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<HTMLElement>(".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<HTMLElement>(".feed-edit-overlay");
if (!overlay) return;
// Collect edited values
const updates: Record<string, string> = {};
overlay.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(".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<HTMLElement>(".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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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;
}
`;