/** * — 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 = { x: "\ud835\udd4f", linkedin: "in", instagram: "\ud83d\udcf7", youtube: "\u25b6\ufe0f", threads: "\ud83e\uddf5", bluesky: "\ud83e\udd8b", }; const PLATFORM_COLORS: Record = { 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 = {}; 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 getSchedulerUrl(): string { return `${this.getApiBase()}/scheduler`; } 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 `${icon}`; }).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 `
${icon} ${this.esc(preview)}
`; }).join(""); } return `
\ud83d\udce2 ${this.esc(camp.title)} ${node.expanded ? "\u25b2" : "\u25bc"}
${this.esc(camp.duration)}
${platformIcons}
${scheduled} scheduled ${draft} draft
${postsHtml}
`; } 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 `
${i + 1}. ${this.esc(preview)}
`; }).join(""); } return `
\ud83e\uddf5 ${this.esc(thread.title)} ${node.expanded ? "\u25b2" : "\u25bc"}
${initial} ${this.esc(thread.handle || thread.name)} ${thread.tweets.length} tweet${thread.tweets.length === 1 ? "" : "s"} \u00b7 ${dateStr}
${tweetsHtml}
`; } private render() { const nodesSvg = this.canvasNodes.map(node => { if (node.type === "campaign") return this.renderCampaignNode(node); return this.renderThreadNode(node); }).join(""); this.shadow.innerHTML = `
\ud83d\udce2 rSocials Canvas${this.space === "demo" ? 'Demo' : ""}
${nodesSvg}
${Math.round(this.canvasZoom * 100)}%
Postiz — Post Scheduler
`; 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 = "${this.getSchedulerUrl()}"; 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);