feat: add interactive canvas to rNetwork + rSocials canvas view
rNetwork: upgrade folk-graph-viewer with pan/zoom/drag, fitView, touch pinch-zoom, zoom controls, persisted node positions, and incremental drag updates. rSocials: new folk-socials-canvas component with campaign and thread card nodes, Postiz slide-out panel, and canvas interactions. Default / route now renders canvas view; old feed moved to ?view=feed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
798a5edb65
commit
cbe9fb5103
|
|
@ -3,6 +3,7 @@
|
||||||
*
|
*
|
||||||
* Displays network nodes (people, companies, opportunities)
|
* Displays network nodes (people, companies, opportunities)
|
||||||
* and edges in a force-directed layout with search and filtering.
|
* and edges in a force-directed layout with search and filtering.
|
||||||
|
* Interactive canvas with pan/zoom/drag (rFlows-style).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface GraphNode {
|
interface GraphNode {
|
||||||
|
|
@ -34,6 +35,26 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
private error = "";
|
private error = "";
|
||||||
private selectedNode: GraphNode | null = null;
|
private selectedNode: GraphNode | null = null;
|
||||||
|
|
||||||
|
// Canvas state
|
||||||
|
private canvasZoom = 1;
|
||||||
|
private canvasPanX = 0;
|
||||||
|
private canvasPanY = 0;
|
||||||
|
private draggingNodeId: string | null = null;
|
||||||
|
private dragStartX = 0;
|
||||||
|
private dragStartY = 0;
|
||||||
|
private dragNodeStartX = 0;
|
||||||
|
private dragNodeStartY = 0;
|
||||||
|
private isPanning = false;
|
||||||
|
private panStartX = 0;
|
||||||
|
private panStartY = 0;
|
||||||
|
private panStartPanX = 0;
|
||||||
|
private panStartPanY = 0;
|
||||||
|
private isTouchPanning = false;
|
||||||
|
private lastTouchCenter: { x: number; y: number } | null = null;
|
||||||
|
private lastTouchDist: number | null = null;
|
||||||
|
private nodePositions: Record<string, { x: number; y: number }> = {};
|
||||||
|
private layoutDirty = true; // recompute layout when true
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
|
@ -64,12 +85,12 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
// People — Commons DAO
|
// People — Commons DAO
|
||||||
{ id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" },
|
{ id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" },
|
||||||
{ id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" },
|
{ id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" },
|
||||||
{ id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "S\u00e3o Paulo" },
|
{ id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "São Paulo" },
|
||||||
{ id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" },
|
{ id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" },
|
||||||
|
|
||||||
// People — Mycelial Lab
|
// People — Mycelial Lab
|
||||||
{ id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" },
|
{ id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" },
|
||||||
{ id: "p-6", name: "Frank M\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" },
|
{ id: "p-6", name: "Frank Müller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" },
|
||||||
{ id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" },
|
{ id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" },
|
||||||
|
|
||||||
// People — Regenerative Fund
|
// People — Regenerative Fund
|
||||||
|
|
@ -97,12 +118,14 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
{ source: "p-10", target: "org-3", type: "work_at" },
|
{ source: "p-10", target: "org-3", type: "work_at" },
|
||||||
|
|
||||||
// Cross-org point_of_contact edges
|
// Cross-org point_of_contact edges
|
||||||
{ source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice \u2194 Frank" },
|
{ source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice ↔ Frank" },
|
||||||
{ source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" },
|
{ source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob ↔ Carol" },
|
||||||
{ source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" },
|
{ source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave ↔ Grace" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.layoutDirty = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.fitView());
|
||||||
}
|
}
|
||||||
|
|
||||||
private getApiBase(): string {
|
private getApiBase(): string {
|
||||||
|
|
@ -126,7 +149,9 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
this.importGraph(graph);
|
this.importGraph(graph);
|
||||||
}
|
}
|
||||||
} catch { /* offline */ }
|
} catch { /* offline */ }
|
||||||
|
this.layoutDirty = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.fitView());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map server /api/graph response to client GraphNode/GraphEdge format */
|
/** Map server /api/graph response to client GraphNode/GraphEdge format */
|
||||||
|
|
@ -201,6 +226,13 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureLayout() {
|
||||||
|
if (!this.layoutDirty && Object.keys(this.nodePositions).length > 0) return;
|
||||||
|
const W = 800, H = 600;
|
||||||
|
this.nodePositions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
||||||
|
this.layoutDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
|
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
|
||||||
const pos: Record<string, { x: number; y: number }> = {};
|
const pos: Record<string, { x: number; y: number }> = {};
|
||||||
|
|
||||||
|
|
@ -292,8 +324,6 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
for (const id of allIds) {
|
for (const id of allIds) {
|
||||||
pos[id].x += force[id].fx * damping;
|
pos[id].x += force[id].fx * damping;
|
||||||
pos[id].y += force[id].fy * damping;
|
pos[id].y += force[id].fy * damping;
|
||||||
pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x));
|
|
||||||
pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pos;
|
return pos;
|
||||||
|
|
@ -336,7 +366,116 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderGraphNodes(): string {
|
// ── Canvas transform helpers ──
|
||||||
|
|
||||||
|
private updateCanvasTransform() {
|
||||||
|
const g = this.shadow.getElementById("canvas-transform");
|
||||||
|
if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fitView() {
|
||||||
|
const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
this.ensureLayout();
|
||||||
|
const filtered = this.getFilteredNodes();
|
||||||
|
const positions = filtered.map(n => this.nodePositions[n.id]).filter(Boolean);
|
||||||
|
if (positions.length === 0) return;
|
||||||
|
|
||||||
|
const pad = 60;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const p of positions) {
|
||||||
|
if (p.x < minX) minX = p.x;
|
||||||
|
if (p.y < minY) minY = p.y;
|
||||||
|
if (p.x > maxX) maxX = p.x;
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
// Include cluster radius for org nodes
|
||||||
|
minX -= 40; minY -= 40; maxX += 40; maxY += 40;
|
||||||
|
|
||||||
|
const contentW = maxX - minX;
|
||||||
|
const contentH = maxY - minY;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const svgW = rect.width || 800;
|
||||||
|
const svgH = rect.height || 600;
|
||||||
|
|
||||||
|
const zoom = Math.min((svgW - pad * 2) / Math.max(contentW, 1), (svgH - pad * 2) / Math.max(contentH, 1), 2);
|
||||||
|
this.canvasZoom = Math.max(0.1, Math.min(zoom, 4));
|
||||||
|
this.canvasPanX = (svgW / 2) - ((minX + maxX) / 2) * this.canvasZoom;
|
||||||
|
this.canvasPanY = (svgH / 2) - ((minY + maxY) / 2) * this.canvasZoom;
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
this.updateZoomDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateZoomDisplay() {
|
||||||
|
const el = this.shadow.getElementById("zoom-level");
|
||||||
|
if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Incremental node update (drag) ──
|
||||||
|
|
||||||
|
private updateNodePosition(nodeId: string) {
|
||||||
|
const pos = this.nodePositions[nodeId];
|
||||||
|
if (!pos) return;
|
||||||
|
const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null;
|
||||||
|
if (!g) return;
|
||||||
|
|
||||||
|
// Update all circles and texts that use absolute coordinates
|
||||||
|
const node = this.nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const isOrg = node.type === "company";
|
||||||
|
const radius = isOrg ? 22 : 12;
|
||||||
|
|
||||||
|
// Update circles
|
||||||
|
g.querySelectorAll("circle").forEach(circle => {
|
||||||
|
const rAttr = circle.getAttribute("r");
|
||||||
|
const r = parseFloat(rAttr || "0");
|
||||||
|
circle.setAttribute("cx", String(pos.x));
|
||||||
|
circle.setAttribute("cy", String(pos.y));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update text elements by relative offset from center
|
||||||
|
const texts = g.querySelectorAll("text");
|
||||||
|
if (isOrg) {
|
||||||
|
// [0] inner label, [1] name below, [2] description, [3+] trust badge
|
||||||
|
if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + 4)); }
|
||||||
|
if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 13)); }
|
||||||
|
if (texts[2]) { texts[2].setAttribute("x", String(pos.x)); texts[2].setAttribute("y", String(pos.y + radius + 26)); }
|
||||||
|
} else {
|
||||||
|
// [0] name below, [1] role/location, [2] trust text
|
||||||
|
if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); }
|
||||||
|
if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 24)); }
|
||||||
|
if (texts[2]) { texts[2].setAttribute("x", String(pos.x + radius - 2)); texts[2].setAttribute("y", String(pos.y - radius + 5.5)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update connected edges
|
||||||
|
for (const edge of this.edges) {
|
||||||
|
if (edge.source !== nodeId && edge.target !== nodeId) continue;
|
||||||
|
const sp = this.nodePositions[edge.source];
|
||||||
|
const tp = this.nodePositions[edge.target];
|
||||||
|
if (!sp || !tp) continue;
|
||||||
|
|
||||||
|
// Find the edge line element
|
||||||
|
const lines = this.shadow.querySelectorAll(`#edge-layer line`);
|
||||||
|
for (const line of lines) {
|
||||||
|
const x1 = parseFloat(line.getAttribute("x1") || "0");
|
||||||
|
const y1 = parseFloat(line.getAttribute("y1") || "0");
|
||||||
|
const x2 = parseFloat(line.getAttribute("x2") || "0");
|
||||||
|
const y2 = parseFloat(line.getAttribute("y2") || "0");
|
||||||
|
// Match by checking if this line connects these nodes (approximate)
|
||||||
|
if (edge.source === nodeId) {
|
||||||
|
// Check if endpoint matches old position pattern — just update all matching edges
|
||||||
|
line.setAttribute("x1", String(sp.x));
|
||||||
|
line.setAttribute("y1", String(sp.y));
|
||||||
|
}
|
||||||
|
if (edge.target === nodeId) {
|
||||||
|
line.setAttribute("x2", String(tp.x));
|
||||||
|
line.setAttribute("y2", String(tp.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGraphSVG(): string {
|
||||||
const filtered = this.getFilteredNodes();
|
const filtered = this.getFilteredNodes();
|
||||||
if (filtered.length === 0 && this.nodes.length > 0) {
|
if (filtered.length === 0 && this.nodes.length > 0) {
|
||||||
return `<div class="placeholder"><p style="font-size:14px;color:var(--rs-text-muted)">No nodes match current filter.</p></div>`;
|
return `<div class="placeholder"><p style="font-size:14px;color:var(--rs-text-muted)">No nodes match current filter.</p></div>`;
|
||||||
|
|
@ -352,13 +491,10 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const W = 700;
|
this.ensureLayout();
|
||||||
const H = 500;
|
const positions = this.nodePositions;
|
||||||
const filteredIds = new Set(filtered.map(n => n.id));
|
const filteredIds = new Set(filtered.map(n => n.id));
|
||||||
|
|
||||||
// Force-directed layout
|
|
||||||
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
|
||||||
|
|
||||||
// Assign colors to companies dynamically
|
// Assign colors to companies dynamically
|
||||||
const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"];
|
const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"];
|
||||||
const companies = this.nodes.filter(n => n.type === "company");
|
const companies = this.nodes.filter(n => n.type === "company");
|
||||||
|
|
@ -368,7 +504,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
// Cluster backgrounds based on computed positions
|
// Cluster backgrounds based on computed positions
|
||||||
const clustersSvg = companies.map(org => {
|
const clustersSvg = companies.map(org => {
|
||||||
const pos = positions[org.id];
|
const pos = positions[org.id];
|
||||||
if (!pos) return "";
|
if (!pos || !filteredIds.has(org.id)) return "";
|
||||||
const color = orgColors[org.id] || "#333";
|
const color = orgColors[org.id] || "#333";
|
||||||
return `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
return `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
@ -393,7 +529,6 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
} else if (edge.type === "collaborates") {
|
} else if (edge.type === "collaborates") {
|
||||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.4"/>`);
|
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.4"/>`);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for any unknown edge type
|
|
||||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" style="stroke:var(--rs-text-muted)" stroke-width="1" opacity="0.25"/>`);
|
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" style="stroke:var(--rs-text-muted)" stroke-width="1" opacity="0.25"/>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +547,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
if (isOrg && node.description) {
|
if (isOrg && node.description) {
|
||||||
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 26}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.description)}</text>`;
|
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 26}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.description)}</text>`;
|
||||||
} else if (!isOrg && node.role) {
|
} else if (!isOrg && node.role) {
|
||||||
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
|
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " · " + this.esc(node.location) : ""}</text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust score badge for people
|
// Trust score badge for people
|
||||||
|
|
@ -434,19 +569,33 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" style="max-height:500px">${clustersSvg}${edgesSvg.join("")}${nodesSvg}</svg>`;
|
return `
|
||||||
|
<svg id="graph-svg" width="100%" height="100%">
|
||||||
|
<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
|
||||||
|
<g id="cluster-layer">${clustersSvg}</g>
|
||||||
|
<g id="edge-layer">${edgesSvg.join("")}</g>
|
||||||
|
<g id="node-layer">${nodesSvg}</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
||||||
|
<span class="zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
|
||||||
|
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
||||||
|
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
|
:host { display: flex; flex-direction: column; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); height: 100%; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); }
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); }
|
||||||
|
|
||||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
.search-input {
|
.search-input {
|
||||||
border: 1px solid var(--rs-input-border); border-radius: 8px; padding: 8px 12px;
|
border: 1px solid var(--rs-input-border); border-radius: 8px; padding: 8px 12px;
|
||||||
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 13px; width: 200px; outline: none;
|
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 13px; width: 200px; outline: none;
|
||||||
|
|
@ -460,14 +609,32 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
|
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
|
||||||
|
|
||||||
.graph-canvas {
|
.graph-canvas {
|
||||||
width: 100%; height: 500px; border-radius: 12px;
|
width: 100%; flex: 1; min-height: 400px; border-radius: 12px;
|
||||||
background: var(--rs-canvas-bg); border: 1px solid var(--rs-border);
|
background: var(--rs-canvas-bg); border: 1px solid var(--rs-border);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
position: relative; overflow: hidden;
|
position: relative; overflow: hidden; cursor: grab;
|
||||||
}
|
}
|
||||||
|
.graph-canvas.grabbing { cursor: grabbing; }
|
||||||
|
.graph-canvas svg { display: block; }
|
||||||
.placeholder { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
.placeholder { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||||||
.placeholder p { margin: 6px 0; }
|
.placeholder p { margin: 6px 0; }
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute; bottom: 12px; right: 12px;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||||
|
border-radius: 8px; padding: 4px 6px; z-index: 5;
|
||||||
|
}
|
||||||
|
.zoom-btn {
|
||||||
|
width: 28px; height: 28px; border: none; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--rs-text-primary); font-size: 16px;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.zoom-btn:hover { background: var(--rs-bg-surface-raised); }
|
||||||
|
.zoom-btn--fit { font-size: 14px; }
|
||||||
|
.zoom-level { font-size: 11px; color: var(--rs-text-muted); min-width: 36px; text-align: center; }
|
||||||
|
|
||||||
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||||
.ws-card {
|
.ws-card {
|
||||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
|
||||||
|
|
@ -517,7 +684,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
.conn-role { font-size: 11px; color: var(--rs-text-muted); margin-left: auto; }
|
.conn-role { font-size: 11px; color: var(--rs-text-muted); margin-left: auto; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.graph-canvas { height: 350px; }
|
.graph-canvas { min-height: 300px; }
|
||||||
.workspace-list { grid-template-columns: 1fr; }
|
.workspace-list { grid-template-columns: 1fr; }
|
||||||
.stats { flex-wrap: wrap; gap: 12px; }
|
.stats { flex-wrap: wrap; gap: 12px; }
|
||||||
.toolbar { flex-direction: column; align-items: stretch; }
|
.toolbar { flex-direction: column; align-items: stretch; }
|
||||||
|
|
@ -547,8 +714,8 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
}).join("")}
|
}).join("")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graph-canvas">
|
<div class="graph-canvas" id="graph-canvas">
|
||||||
${this.nodes.length > 0 ? this.renderGraphNodes() : `
|
${this.nodes.length > 0 ? this.renderGraphSVG() : `
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
<p style="font-size:48px">🕸️</p>
|
<p style="font-size:48px">🕸️</p>
|
||||||
<p style="font-size:16px">Community Relationship Graph</p>
|
<p style="font-size:16px">Community Relationship Graph</p>
|
||||||
|
|
@ -583,27 +750,24 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
// Filter buttons
|
||||||
this.shadow.querySelectorAll("[data-filter]").forEach(el => {
|
this.shadow.querySelectorAll("[data-filter]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
this.filter = (el as HTMLElement).dataset.filter as any;
|
this.filter = (el as HTMLElement).dataset.filter as any;
|
||||||
this.render();
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.fitView());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
let searchTimeout: any;
|
let searchTimeout: any;
|
||||||
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
||||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => this.render(), 200);
|
searchTimeout = setTimeout(() => {
|
||||||
});
|
|
||||||
|
|
||||||
// Node click → detail panel
|
|
||||||
this.shadow.querySelectorAll("[data-node-id]").forEach(el => {
|
|
||||||
el.addEventListener("click", () => {
|
|
||||||
const id = (el as HTMLElement).dataset.nodeId!;
|
|
||||||
if (this.selectedNode?.id === id) { this.selectedNode = null; }
|
|
||||||
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
|
|
||||||
this.render();
|
this.render();
|
||||||
});
|
requestAnimationFrame(() => this.fitView());
|
||||||
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close detail panel
|
// Close detail panel
|
||||||
|
|
@ -611,6 +775,161 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
this.selectedNode = null;
|
this.selectedNode = null;
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zoom controls
|
||||||
|
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
|
||||||
|
const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const cx = rect.width / 2, cy = rect.height / 2;
|
||||||
|
this.zoomAt(cx, cy, 1.25);
|
||||||
|
});
|
||||||
|
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
|
||||||
|
const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const cx = rect.width / 2, cy = rect.height / 2;
|
||||||
|
this.zoomAt(cx, cy, 0.8);
|
||||||
|
});
|
||||||
|
this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => this.fitView());
|
||||||
|
|
||||||
|
// Canvas interactions
|
||||||
|
const canvas = this.shadow.getElementById("graph-canvas");
|
||||||
|
const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg || !canvas) return;
|
||||||
|
|
||||||
|
// Wheel zoom
|
||||||
|
svg.addEventListener("wheel", (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const factor = 1 - e.deltaY * 0.003;
|
||||||
|
this.zoomAt(mx, my, factor);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Pointer down — node drag or canvas pan
|
||||||
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const target = (e.target as Element).closest("[data-node-id]") as HTMLElement | null;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
// Node drag
|
||||||
|
const nodeId = target.dataset.nodeId!;
|
||||||
|
this.draggingNodeId = nodeId;
|
||||||
|
this.dragStartX = e.clientX;
|
||||||
|
this.dragStartY = e.clientY;
|
||||||
|
const pos = this.nodePositions[nodeId];
|
||||||
|
if (pos) {
|
||||||
|
this.dragNodeStartX = pos.x;
|
||||||
|
this.dragNodeStartY = pos.y;
|
||||||
|
}
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
// Canvas pan
|
||||||
|
this.isPanning = true;
|
||||||
|
this.panStartX = e.clientX;
|
||||||
|
this.panStartY = e.clientY;
|
||||||
|
this.panStartPanX = this.canvasPanX;
|
||||||
|
this.panStartPanY = this.canvasPanY;
|
||||||
|
canvas.classList.add("grabbing");
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("pointermove", (e: PointerEvent) => {
|
||||||
|
if (this.draggingNodeId) {
|
||||||
|
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
|
||||||
|
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
|
||||||
|
const pos = this.nodePositions[this.draggingNodeId];
|
||||||
|
if (pos) {
|
||||||
|
pos.x = this.dragNodeStartX + dx;
|
||||||
|
pos.y = this.dragNodeStartY + dy;
|
||||||
|
this.updateNodePosition(this.draggingNodeId);
|
||||||
|
}
|
||||||
|
} else if (this.isPanning) {
|
||||||
|
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
|
||||||
|
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("pointerup", (e: PointerEvent) => {
|
||||||
|
if (this.draggingNodeId) {
|
||||||
|
// If barely moved, treat as click → select node
|
||||||
|
const dx = Math.abs(e.clientX - this.dragStartX);
|
||||||
|
const dy = Math.abs(e.clientY - this.dragStartY);
|
||||||
|
if (dx < 4 && dy < 4) {
|
||||||
|
const id = this.draggingNodeId;
|
||||||
|
if (this.selectedNode?.id === id) { this.selectedNode = null; }
|
||||||
|
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
|
||||||
|
this.draggingNodeId = null;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.draggingNodeId = null;
|
||||||
|
}
|
||||||
|
if (this.isPanning) {
|
||||||
|
this.isPanning = false;
|
||||||
|
canvas.classList.remove("grabbing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch pinch-zoom
|
||||||
|
svg.addEventListener("touchstart", (e: TouchEvent) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isTouchPanning = true;
|
||||||
|
const t0 = e.touches[0], t1 = e.touches[1];
|
||||||
|
this.lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
|
||||||
|
this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
svg.addEventListener("touchmove", (e: TouchEvent) => {
|
||||||
|
if (e.touches.length === 2 && this.isTouchPanning) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t0 = e.touches[0], t1 = e.touches[1];
|
||||||
|
const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
|
||||||
|
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||||
|
|
||||||
|
if (this.lastTouchCenter && this.lastTouchDist) {
|
||||||
|
// Pan
|
||||||
|
this.canvasPanX += center.x - this.lastTouchCenter.x;
|
||||||
|
this.canvasPanY += center.y - this.lastTouchCenter.y;
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const mx = center.x - rect.left;
|
||||||
|
const my = center.y - rect.top;
|
||||||
|
const factor = dist / this.lastTouchDist;
|
||||||
|
this.zoomAt(mx, my, factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastTouchCenter = center;
|
||||||
|
this.lastTouchDist = dist;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
svg.addEventListener("touchend", () => {
|
||||||
|
this.isTouchPanning = false;
|
||||||
|
this.lastTouchCenter = null;
|
||||||
|
this.lastTouchDist = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomAt(screenX: number, screenY: number, factor: number) {
|
||||||
|
const oldZoom = this.canvasZoom;
|
||||||
|
const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
|
||||||
|
// Adjust pan so the point under the cursor stays fixed
|
||||||
|
this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
|
||||||
|
this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
|
||||||
|
this.canvasZoom = newZoom;
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
this.updateZoomDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
private esc(s: string): string {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
/* Network module — dark theme */
|
/* Network module — dark theme */
|
||||||
folk-graph-viewer {
|
folk-graph-viewer {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Canvas cursor states */
|
||||||
|
folk-graph-viewer .graph-canvas { cursor: grab; }
|
||||||
|
folk-graph-viewer .graph-canvas.grabbing { cursor: grabbing; }
|
||||||
|
folk-graph-viewer .graph-canvas .graph-node { cursor: pointer; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,590 @@
|
||||||
|
/**
|
||||||
|
* <folk-socials-canvas> — campaign & thread canvas view.
|
||||||
|
*
|
||||||
|
* Renders campaigns and threads as draggable card nodes on an SVG canvas
|
||||||
|
* with pan/zoom/drag interactions. Includes a Postiz slide-out panel for
|
||||||
|
* post scheduling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CampaignPost {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
postType: string;
|
||||||
|
stepNumber: number;
|
||||||
|
content: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
|
hashtags: string[];
|
||||||
|
phase: number;
|
||||||
|
phaseLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
duration: string;
|
||||||
|
platforms: string[];
|
||||||
|
phases: { name: string; label: string; days: string }[];
|
||||||
|
posts: CampaignPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
handle: string;
|
||||||
|
title: string;
|
||||||
|
tweets: string[];
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanvasNode {
|
||||||
|
id: string;
|
||||||
|
type: "campaign" | "thread";
|
||||||
|
data: Campaign | ThreadData;
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_ICONS: Record<string, string> = {
|
||||||
|
x: "\ud835\udd4f", linkedin: "in", instagram: "\ud83d\udcf7",
|
||||||
|
youtube: "\u25b6\ufe0f", threads: "\ud83e\uddf5", bluesky: "\ud83e\udd8b",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_COLORS: Record<string, string> = {
|
||||||
|
x: "#000000", linkedin: "#0A66C2", instagram: "#E4405F",
|
||||||
|
youtube: "#FF0000", threads: "#000000", bluesky: "#0085FF",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Demo campaign inline (same as server campaign-data.ts)
|
||||||
|
const DEMO_CAMPAIGN: Campaign = {
|
||||||
|
id: "mycofi-earth-launch",
|
||||||
|
title: "MycoFi Earth Launch Campaign",
|
||||||
|
description: "Multi-platform product launch campaign for MycoFi Earth.",
|
||||||
|
duration: "Feb 21\u201325, 2026 (5 days)",
|
||||||
|
platforms: ["X", "LinkedIn", "Instagram", "YouTube", "Threads", "Bluesky"],
|
||||||
|
phases: [
|
||||||
|
{ name: "pre-launch", label: "Pre-Launch Hype", days: "Day -3 to -1" },
|
||||||
|
{ name: "launch", label: "Launch Day", days: "Day 0" },
|
||||||
|
{ name: "amplification", label: "Amplification", days: "Day +1" },
|
||||||
|
],
|
||||||
|
posts: [
|
||||||
|
{ id: "post-x-teaser", platform: "x", postType: "thread", stepNumber: 1, content: "Something is growing in the mycelium... \ud83c\udf44", scheduledAt: "2026-02-21T09:00:00", status: "scheduled", hashtags: ["MycoFi"], phase: 1, phaseLabel: "Pre-Launch Hype" },
|
||||||
|
{ id: "post-linkedin-thought", platform: "linkedin", postType: "article", stepNumber: 2, content: "The regenerative finance movement isn't just about returns...", scheduledAt: "2026-02-22T11:00:00", status: "scheduled", hashtags: ["RegenerativeFinance"], phase: 1, phaseLabel: "Pre-Launch Hype" },
|
||||||
|
{ id: "post-ig-carousel", platform: "instagram", postType: "carousel", stepNumber: 3, content: "5 Ways Mycelium Networks Mirror the Future of Finance", scheduledAt: "2026-02-23T14:00:00", status: "scheduled", hashtags: ["MycoFi"], phase: 1, phaseLabel: "Pre-Launch Hype" },
|
||||||
|
{ id: "post-yt-launch", platform: "youtube", postType: "video", stepNumber: 4, content: "MycoFi Earth \u2014 Official Launch Video", scheduledAt: "2026-02-24T10:00:00", status: "draft", hashtags: ["MycoFiLaunch"], phase: 2, phaseLabel: "Launch Day" },
|
||||||
|
{ id: "post-x-launch", platform: "x", postType: "thread", stepNumber: 5, content: "\ud83c\udf44 MycoFi Earth is LIVE \ud83c\udf44", scheduledAt: "2026-02-24T10:15:00", status: "draft", hashtags: ["MycoFi", "Launch"], phase: 2, phaseLabel: "Launch Day" },
|
||||||
|
{ id: "post-threads-xpost", platform: "threads", postType: "text", stepNumber: 8, content: "We just launched MycoFi Earth and the response has been incredible", scheduledAt: "2026-02-25T14:00:00", status: "draft", hashtags: ["MycoFi"], phase: 3, phaseLabel: "Amplification" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_THREADS: ThreadData[] = [
|
||||||
|
{ id: "t-demo-1", name: "Jeff Emmett", handle: "@jeffemmett", title: "Why Regenerative Finance Needs Mycelial Networks", tweets: ["The old financial system is composting itself. Here's why \ud83e\uddf5\ud83d\udc47", "Mycelium doesn't hoard \u2014 it redistributes nutrients.", "What if finance worked the same way?"], createdAt: Date.now() - 86400000, updatedAt: Date.now() - 3600000 },
|
||||||
|
{ id: "t-demo-2", name: "Commons DAO", handle: "@commonsdao", title: "Governance Patterns for Web3 Communities", tweets: ["Thread: 7 governance patterns we've tested at Commons DAO", "1/ Conviction voting \u2014 signal strength over time", "2/ Composting \u2014 failed proposals return resources to the pool"], createdAt: Date.now() - 172800000, updatedAt: Date.now() - 86400000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
class FolkSocialsCanvas extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = "";
|
||||||
|
|
||||||
|
// Data
|
||||||
|
private campaigns: Campaign[] = [];
|
||||||
|
private threads: ThreadData[] = [];
|
||||||
|
private canvasNodes: CanvasNode[] = [];
|
||||||
|
|
||||||
|
// Canvas state
|
||||||
|
private canvasZoom = 1;
|
||||||
|
private canvasPanX = 0;
|
||||||
|
private canvasPanY = 0;
|
||||||
|
private nodePositions: Record<string, { x: number; y: number }> = {};
|
||||||
|
private draggingNodeId: string | null = null;
|
||||||
|
private dragStartX = 0;
|
||||||
|
private dragStartY = 0;
|
||||||
|
private dragNodeStartX = 0;
|
||||||
|
private dragNodeStartY = 0;
|
||||||
|
private isPanning = false;
|
||||||
|
private panStartX = 0;
|
||||||
|
private panStartY = 0;
|
||||||
|
private panStartPanX = 0;
|
||||||
|
private panStartPanY = 0;
|
||||||
|
private isTouchPanning = false;
|
||||||
|
private lastTouchCenter: { x: number; y: number } | null = null;
|
||||||
|
private lastTouchDist: number | null = null;
|
||||||
|
|
||||||
|
// Postiz panel
|
||||||
|
private postizOpen = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadData() {
|
||||||
|
if (this.space === "demo") {
|
||||||
|
this.campaigns = [DEMO_CAMPAIGN];
|
||||||
|
this.threads = DEMO_THREADS;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const base = this.getApiBase();
|
||||||
|
const [campRes, threadRes] = await Promise.all([
|
||||||
|
fetch(`${base}/api/campaigns`).catch(() => null),
|
||||||
|
fetch(`${base}/api/threads`).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (campRes?.ok) {
|
||||||
|
const data = await campRes.json();
|
||||||
|
this.campaigns = Array.isArray(data) ? data : (data.campaigns || [DEMO_CAMPAIGN]);
|
||||||
|
} else {
|
||||||
|
this.campaigns = [DEMO_CAMPAIGN];
|
||||||
|
}
|
||||||
|
if (threadRes?.ok) {
|
||||||
|
this.threads = await threadRes.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.campaigns = [DEMO_CAMPAIGN];
|
||||||
|
this.threads = DEMO_THREADS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildCanvasNodes();
|
||||||
|
this.layoutNodes();
|
||||||
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.fitView());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApiBase(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/^(\/[^/]+)?\/rsocials/);
|
||||||
|
return match ? match[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCanvasNodes() {
|
||||||
|
this.canvasNodes = [];
|
||||||
|
for (const c of this.campaigns) {
|
||||||
|
this.canvasNodes.push({ id: `camp-${c.id}`, type: "campaign", data: c, expanded: false });
|
||||||
|
}
|
||||||
|
for (const t of this.threads) {
|
||||||
|
this.canvasNodes.push({ id: `thread-${t.id}`, type: "thread", data: t, expanded: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private layoutNodes() {
|
||||||
|
// Grid layout: campaigns top row, threads below
|
||||||
|
const campGap = 380;
|
||||||
|
const threadGap = 340;
|
||||||
|
let cx = 50;
|
||||||
|
for (const node of this.canvasNodes) {
|
||||||
|
if (node.type === "campaign") {
|
||||||
|
this.nodePositions[node.id] = { x: cx, y: 50 };
|
||||||
|
cx += campGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tx = 50;
|
||||||
|
for (const node of this.canvasNodes) {
|
||||||
|
if (node.type === "thread") {
|
||||||
|
this.nodePositions[node.id] = { x: tx, y: 350 };
|
||||||
|
tx += threadGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNodeSize(node: CanvasNode): { w: number; h: number } {
|
||||||
|
if (node.type === "campaign") {
|
||||||
|
const camp = node.data as Campaign;
|
||||||
|
if (node.expanded) {
|
||||||
|
return { w: 320, h: 200 + camp.posts.length * 48 };
|
||||||
|
}
|
||||||
|
return { w: 320, h: 200 };
|
||||||
|
} else {
|
||||||
|
const thread = node.data as ThreadData;
|
||||||
|
if (node.expanded) {
|
||||||
|
return { w: 280, h: 140 + thread.tweets.length * 44 };
|
||||||
|
}
|
||||||
|
return { w: 280, h: 140 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas transform ──
|
||||||
|
|
||||||
|
private updateCanvasTransform() {
|
||||||
|
const g = this.shadow.getElementById("canvas-transform");
|
||||||
|
if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fitView() {
|
||||||
|
const svg = this.shadow.getElementById("socials-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
if (this.canvasNodes.length === 0) return;
|
||||||
|
|
||||||
|
const pad = 60;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const node of this.canvasNodes) {
|
||||||
|
const pos = this.nodePositions[node.id];
|
||||||
|
if (!pos) continue;
|
||||||
|
const size = this.getNodeSize(node);
|
||||||
|
if (pos.x < minX) minX = pos.x;
|
||||||
|
if (pos.y < minY) minY = pos.y;
|
||||||
|
if (pos.x + size.w > maxX) maxX = pos.x + size.w;
|
||||||
|
if (pos.y + size.h > maxY) maxY = pos.y + size.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentW = maxX - minX;
|
||||||
|
const contentH = maxY - minY;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const svgW = rect.width || 800;
|
||||||
|
const svgH = rect.height || 600;
|
||||||
|
|
||||||
|
const zoom = Math.min((svgW - pad * 2) / Math.max(contentW, 1), (svgH - pad * 2) / Math.max(contentH, 1), 2);
|
||||||
|
this.canvasZoom = Math.max(0.1, Math.min(zoom, 4));
|
||||||
|
this.canvasPanX = (svgW / 2) - ((minX + maxX) / 2) * this.canvasZoom;
|
||||||
|
this.canvasPanY = (svgH / 2) - ((minY + maxY) / 2) * this.canvasZoom;
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
this.updateZoomDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateZoomDisplay() {
|
||||||
|
const el = this.shadow.getElementById("zoom-level");
|
||||||
|
if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomAt(screenX: number, screenY: number, factor: number) {
|
||||||
|
const oldZoom = this.canvasZoom;
|
||||||
|
const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
|
||||||
|
this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
|
||||||
|
this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
|
||||||
|
this.canvasZoom = newZoom;
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
this.updateZoomDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
|
||||||
|
private renderCampaignNode(node: CanvasNode): string {
|
||||||
|
const camp = node.data as Campaign;
|
||||||
|
const pos = this.nodePositions[node.id];
|
||||||
|
if (!pos) return "";
|
||||||
|
const { w, h } = this.getNodeSize(node);
|
||||||
|
|
||||||
|
const scheduled = camp.posts.filter(p => p.status === "scheduled").length;
|
||||||
|
const draft = camp.posts.filter(p => p.status === "draft").length;
|
||||||
|
const phaseProgress = Math.round((scheduled / Math.max(camp.posts.length, 1)) * 100);
|
||||||
|
|
||||||
|
const platformIcons = camp.platforms.map(p => {
|
||||||
|
const key = p.toLowerCase();
|
||||||
|
const icon = PLATFORM_ICONS[key] || p.charAt(0);
|
||||||
|
const color = PLATFORM_COLORS[key] || "#888";
|
||||||
|
return `<span style="display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:4px;background:${color}22;color:${color};font-size:10px;font-weight:700">${icon}</span>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
let postsHtml = "";
|
||||||
|
if (node.expanded) {
|
||||||
|
postsHtml = camp.posts.map(p => {
|
||||||
|
const icon = PLATFORM_ICONS[p.platform] || p.platform;
|
||||||
|
const statusColor = p.status === "scheduled" ? "#22c55e" : "#f59e0b";
|
||||||
|
const preview = p.content.substring(0, 60) + (p.content.length > 60 ? "\u2026" : "");
|
||||||
|
return `
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;gap:6px;padding:6px 8px;border-top:1px solid rgba(255,255,255,0.06);font-size:11px;color:#ccc">
|
||||||
|
<span style="font-size:10px">${icon}</span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this.esc(preview)}</span>
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:${statusColor};flex-shrink:0"></span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<g class="canvas-node" data-canvas-node="${node.id}" style="cursor:pointer">
|
||||||
|
<foreignObject x="${pos.x}" y="${pos.y}" width="${w}" height="${h}">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#1a1a2e;border:1px solid #2d2d44;border-radius:12px;overflow:hidden;font-family:system-ui,-apple-system,sans-serif">
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.06)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<span style="font-size:16px">\ud83d\udce2</span>
|
||||||
|
<span style="font-size:13px;font-weight:700;color:#e2e8f0;flex:1">${this.esc(camp.title)}</span>
|
||||||
|
<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#6366f122;color:#818cf8">${node.expanded ? "\u25b2" : "\u25bc"}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#94a3b8;margin-bottom:10px">${this.esc(camp.duration)}</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-bottom:10px">${platformIcons}</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;font-size:11px;color:#94a3b8">
|
||||||
|
<div style="flex:1;height:4px;background:#1e293b;border-radius:2px;overflow:hidden">
|
||||||
|
<div style="width:${phaseProgress}%;height:100%;background:#6366f1;border-radius:2px"></div>
|
||||||
|
</div>
|
||||||
|
<span>${scheduled} scheduled</span>
|
||||||
|
<span style="color:#f59e0b">${draft} draft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${postsHtml}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderThreadNode(node: CanvasNode): string {
|
||||||
|
const thread = node.data as ThreadData;
|
||||||
|
const pos = this.nodePositions[node.id];
|
||||||
|
if (!pos) return "";
|
||||||
|
const { w, h } = this.getNodeSize(node);
|
||||||
|
|
||||||
|
const initial = (thread.name || "?").charAt(0).toUpperCase();
|
||||||
|
const dateStr = new Date(thread.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
|
||||||
|
let tweetsHtml = "";
|
||||||
|
if (node.expanded) {
|
||||||
|
tweetsHtml = thread.tweets.map((t, i) => {
|
||||||
|
const preview = t.substring(0, 80) + (t.length > 80 ? "\u2026" : "");
|
||||||
|
return `
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="padding:6px 8px;border-top:1px solid rgba(255,255,255,0.06);font-size:11px;color:#ccc;display:flex;gap:6px">
|
||||||
|
<span style="color:#6366f1;font-weight:700;flex-shrink:0">${i + 1}.</span>
|
||||||
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this.esc(preview)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<g class="canvas-node" data-canvas-node="${node.id}" style="cursor:pointer">
|
||||||
|
<foreignObject x="${pos.x}" y="${pos.y}" width="${w}" height="${h}">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#1a1a2e;border:1px solid #2d2d44;border-radius:12px;overflow:hidden;font-family:system-ui,-apple-system,sans-serif">
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.06)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||||
|
<span style="font-size:14px">\ud83e\uddf5</span>
|
||||||
|
<span style="font-size:13px;font-weight:700;color:#e2e8f0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this.esc(thread.title)}</span>
|
||||||
|
<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#6366f122;color:#818cf8">${node.expanded ? "\u25b2" : "\u25bc"}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:#94a3b8">
|
||||||
|
<span style="width:18px;height:18px;border-radius:50%;background:#6366f1;display:flex;align-items:center;justify-content:center;color:white;font-size:9px;font-weight:700;flex-shrink:0">${initial}</span>
|
||||||
|
<span>${this.esc(thread.handle || thread.name)}</span>
|
||||||
|
<span style="margin-left:auto">${thread.tweets.length} tweet${thread.tweets.length === 1 ? "" : "s"}</span>
|
||||||
|
<span>\u00b7</span>
|
||||||
|
<span>${dateStr}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${tweetsHtml}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const nodesSvg = this.canvasNodes.map(node => {
|
||||||
|
if (node.type === "campaign") return this.renderCampaignNode(node);
|
||||||
|
return this.renderThreadNode(node);
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="/modules/rsocials/socials-canvas.css">
|
||||||
|
<div class="socials-canvas-root">
|
||||||
|
<div class="sc-toolbar">
|
||||||
|
<span class="sc-toolbar__title">\ud83d\udce2 rSocials Canvas${this.space === "demo" ? '<span class="sc-demo-badge">Demo</span>' : ""}</span>
|
||||||
|
<div class="sc-toolbar__actions">
|
||||||
|
<button class="sc-btn sc-btn--postiz" id="toggle-postiz">Schedule in Postiz</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-canvas-area">
|
||||||
|
<div class="sc-canvas" id="sc-canvas">
|
||||||
|
<svg id="socials-svg" width="100%" height="100%">
|
||||||
|
<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
|
||||||
|
${nodesSvg}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="sc-zoom-controls">
|
||||||
|
<button class="sc-zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
||||||
|
<span class="sc-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
|
||||||
|
<button class="sc-zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
||||||
|
<button class="sc-zoom-btn sc-zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-postiz-panel ${this.postizOpen ? "open" : ""}" id="postiz-panel">
|
||||||
|
<div class="sc-postiz-header">
|
||||||
|
<span class="sc-postiz-title">Postiz — Post Scheduler</span>
|
||||||
|
<button class="sc-postiz-close" id="close-postiz">\u2715</button>
|
||||||
|
</div>
|
||||||
|
<iframe class="sc-postiz-iframe" src="${this.postizOpen ? "https://social.jeffemmett.com" : "about:blank"}" title="Postiz"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachListeners() {
|
||||||
|
// Postiz panel toggle
|
||||||
|
this.shadow.getElementById("toggle-postiz")?.addEventListener("click", () => {
|
||||||
|
this.postizOpen = !this.postizOpen;
|
||||||
|
const panel = this.shadow.getElementById("postiz-panel");
|
||||||
|
const iframe = panel?.querySelector("iframe");
|
||||||
|
if (panel) panel.classList.toggle("open", this.postizOpen);
|
||||||
|
if (iframe && this.postizOpen) iframe.src = "https://social.jeffemmett.com";
|
||||||
|
if (iframe && !this.postizOpen) iframe.src = "about:blank";
|
||||||
|
});
|
||||||
|
this.shadow.getElementById("close-postiz")?.addEventListener("click", () => {
|
||||||
|
this.postizOpen = false;
|
||||||
|
const panel = this.shadow.getElementById("postiz-panel");
|
||||||
|
const iframe = panel?.querySelector("iframe");
|
||||||
|
if (panel) panel.classList.remove("open");
|
||||||
|
if (iframe) iframe.src = "about:blank";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom controls
|
||||||
|
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
|
||||||
|
const svg = this.shadow.getElementById("socials-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
this.zoomAt(rect.width / 2, rect.height / 2, 1.25);
|
||||||
|
});
|
||||||
|
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
|
||||||
|
const svg = this.shadow.getElementById("socials-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
|
||||||
|
});
|
||||||
|
this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => this.fitView());
|
||||||
|
|
||||||
|
// Canvas interactions
|
||||||
|
const canvas = this.shadow.getElementById("sc-canvas");
|
||||||
|
const svg = this.shadow.getElementById("socials-svg") as SVGSVGElement | null;
|
||||||
|
if (!svg || !canvas) return;
|
||||||
|
|
||||||
|
// Node click → expand/collapse
|
||||||
|
this.shadow.querySelectorAll("[data-canvas-node]").forEach(el => {
|
||||||
|
const nodeEl = el as SVGGElement;
|
||||||
|
// We handle click vs drag in pointerup
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wheel zoom
|
||||||
|
svg.addEventListener("wheel", (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const factor = 1 - e.deltaY * 0.003;
|
||||||
|
this.zoomAt(mx, my, factor);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Pointer down — node drag or canvas pan
|
||||||
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const target = (e.target as Element).closest("[data-canvas-node]") as HTMLElement | null;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const nodeId = target.dataset.canvasNode!;
|
||||||
|
this.draggingNodeId = nodeId;
|
||||||
|
this.dragStartX = e.clientX;
|
||||||
|
this.dragStartY = e.clientY;
|
||||||
|
const pos = this.nodePositions[nodeId];
|
||||||
|
if (pos) {
|
||||||
|
this.dragNodeStartX = pos.x;
|
||||||
|
this.dragNodeStartY = pos.y;
|
||||||
|
}
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
this.isPanning = true;
|
||||||
|
this.panStartX = e.clientX;
|
||||||
|
this.panStartY = e.clientY;
|
||||||
|
this.panStartPanX = this.canvasPanX;
|
||||||
|
this.panStartPanY = this.canvasPanY;
|
||||||
|
canvas.classList.add("grabbing");
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("pointermove", (e: PointerEvent) => {
|
||||||
|
if (this.draggingNodeId) {
|
||||||
|
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
|
||||||
|
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
|
||||||
|
const pos = this.nodePositions[this.draggingNodeId];
|
||||||
|
if (pos) {
|
||||||
|
pos.x = this.dragNodeStartX + dx;
|
||||||
|
pos.y = this.dragNodeStartY + dy;
|
||||||
|
// Move the foreignObject directly
|
||||||
|
const g = this.shadow.querySelector(`[data-canvas-node="${this.draggingNodeId}"]`);
|
||||||
|
const fo = g?.querySelector("foreignObject");
|
||||||
|
if (fo) {
|
||||||
|
fo.setAttribute("x", String(pos.x));
|
||||||
|
fo.setAttribute("y", String(pos.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.isPanning) {
|
||||||
|
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
|
||||||
|
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
|
||||||
|
this.updateCanvasTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("pointerup", (e: PointerEvent) => {
|
||||||
|
if (this.draggingNodeId) {
|
||||||
|
const dx = Math.abs(e.clientX - this.dragStartX);
|
||||||
|
const dy = Math.abs(e.clientY - this.dragStartY);
|
||||||
|
if (dx < 4 && dy < 4) {
|
||||||
|
// Click — toggle expand
|
||||||
|
const node = this.canvasNodes.find(n => n.id === this.draggingNodeId);
|
||||||
|
if (node) {
|
||||||
|
node.expanded = !node.expanded;
|
||||||
|
this.draggingNodeId = null;
|
||||||
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.updateCanvasTransform());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.draggingNodeId = null;
|
||||||
|
}
|
||||||
|
if (this.isPanning) {
|
||||||
|
this.isPanning = false;
|
||||||
|
canvas.classList.remove("grabbing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch pinch-zoom
|
||||||
|
svg.addEventListener("touchstart", (e: TouchEvent) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isTouchPanning = true;
|
||||||
|
const t0 = e.touches[0], t1 = e.touches[1];
|
||||||
|
this.lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
|
||||||
|
this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
svg.addEventListener("touchmove", (e: TouchEvent) => {
|
||||||
|
if (e.touches.length === 2 && this.isTouchPanning) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t0 = e.touches[0], t1 = e.touches[1];
|
||||||
|
const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
|
||||||
|
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||||
|
|
||||||
|
if (this.lastTouchCenter && this.lastTouchDist) {
|
||||||
|
this.canvasPanX += center.x - this.lastTouchCenter.x;
|
||||||
|
this.canvasPanY += center.y - this.lastTouchCenter.y;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const mx = center.x - rect.left;
|
||||||
|
const my = center.y - rect.top;
|
||||||
|
const factor = dist / this.lastTouchDist;
|
||||||
|
this.zoomAt(mx, my, factor);
|
||||||
|
}
|
||||||
|
this.lastTouchCenter = center;
|
||||||
|
this.lastTouchDist = dist;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
svg.addEventListener("touchend", () => {
|
||||||
|
this.isTouchPanning = false;
|
||||||
|
this.lastTouchCenter = null;
|
||||||
|
this.lastTouchDist = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private esc(s: string): string {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s || "";
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("folk-socials-canvas", FolkSocialsCanvas);
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
/* rSocials Canvas — dark theme */
|
||||||
|
folk-socials-canvas {
|
||||||
|
display: block;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socials-canvas-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.sc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-bottom: 1px solid var(--rs-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-toolbar__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-demo-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f59e0b22;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--rs-input-border);
|
||||||
|
background: var(--rs-input-bg);
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-btn:hover {
|
||||||
|
border-color: var(--rs-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-btn--postiz {
|
||||||
|
background: #6366f122;
|
||||||
|
border-color: #6366f155;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-btn--postiz:hover {
|
||||||
|
background: #6366f133;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas area — fills remaining space */
|
||||||
|
.sc-canvas-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
background: var(--rs-canvas-bg, #0f0f23);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-canvas.grabbing {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-canvas svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom controls */
|
||||||
|
.sc-zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--rs-bg-surface);
|
||||||
|
border: 1px solid var(--rs-border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-zoom-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-zoom-btn:hover {
|
||||||
|
background: var(--rs-bg-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-zoom-btn--fit {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-zoom-level {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--rs-text-muted);
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Postiz slide-out panel */
|
||||||
|
.sc-postiz-panel {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--rs-border);
|
||||||
|
background: var(--rs-bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-panel.open {
|
||||||
|
width: 60%;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--rs-border);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--rs-text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-close:hover {
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-postiz-iframe {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas node hover */
|
||||||
|
.canvas-node foreignObject div:hover {
|
||||||
|
border-color: #4f46e5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sc-postiz-panel.open {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1965,13 +1965,10 @@ routes.get("/", (c) => {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDemo = space === "demo";
|
if (view === "feed") {
|
||||||
|
const isDemo = space === "demo";
|
||||||
const body = isDemo
|
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
|
||||||
? renderDemoFeedHTML()
|
const demoFeedStyles = `<style>
|
||||||
: renderLanding();
|
|
||||||
|
|
||||||
const demoFeedStyles = `<style>
|
|
||||||
.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
.rsocials-header { margin-bottom: 1.5rem; }
|
.rsocials-header { margin-bottom: 1.5rem; }
|
||||||
.rsocials-header h2 {
|
.rsocials-header h2 {
|
||||||
|
|
@ -2038,22 +2035,41 @@ routes.get("/", (c) => {
|
||||||
padding: 1rem 0; border-top: 1px solid var(--rs-border-subtle); margin-top: 0.5rem;
|
padding: 1rem 0; border-top: 1px solid var(--rs-border-subtle); margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>`;
|
</style>`;
|
||||||
|
const styles = isDemo ? demoFeedStyles : `<style>${RICH_LANDING_CSS}</style>`;
|
||||||
const styles = isDemo
|
return c.html(renderShell({
|
||||||
? demoFeedStyles
|
title: `${space} — Socials Feed | rSpace`,
|
||||||
: `<style>${RICH_LANDING_CSS}</style>`;
|
|
||||||
|
|
||||||
return c.html(
|
|
||||||
renderShell({
|
|
||||||
title: `${space} — Socials | rSpace`,
|
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body,
|
body,
|
||||||
styles,
|
styles,
|
||||||
}),
|
}));
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (view === "landing") {
|
||||||
|
return c.html(renderShell({
|
||||||
|
title: `${space} — rSocials | rSpace`,
|
||||||
|
moduleId: "rsocials",
|
||||||
|
spaceSlug: space,
|
||||||
|
modules: getModuleInfoList(),
|
||||||
|
theme: "dark",
|
||||||
|
body: renderLanding(),
|
||||||
|
styles: `<style>${RICH_LANDING_CSS}</style>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: canvas view
|
||||||
|
return c.html(renderShell({
|
||||||
|
title: `${space} — rSocials | rSpace`,
|
||||||
|
moduleId: "rsocials",
|
||||||
|
spaceSlug: space,
|
||||||
|
modules: getModuleInfoList(),
|
||||||
|
body: `<folk-socials-canvas space="${escapeHtml(space)}"></folk-socials-canvas>`,
|
||||||
|
scripts: `<script type="module" src="/modules/rsocials/folk-socials-canvas.js"></script>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials-canvas.css">`,
|
||||||
|
theme: "dark",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const socialsModule: RSpaceModule = {
|
export const socialsModule: RSpaceModule = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue