fix: add wasm plugin to folk-flows-app build + add missing rsocials components

The folk-flows-app sub-build imports Automerge which requires WASM support.
Added wasm() plugin, esnext target, and Automerge alias to the sub-build config.

Also adds folk-socials-canvas.ts and socials-canvas.css that were on the server
but missing from git, causing the vite build to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 20:34:32 -08:00
parent 02c8aa1fb3
commit aa45cabc4e
3 changed files with 813 additions and 1 deletions

View File

@ -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">&minus;</button>
<button class="sc-zoom-btn sc-zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</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);

View File

@ -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;
}
}

View File

@ -244,8 +244,15 @@ export default defineConfig({
await build({
configFile: false,
root: resolve(__dirname, "modules/rflows/components"),
resolve: { alias: flowsAlias },
plugins: [wasm()],
resolve: {
alias: {
...flowsAlias,
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
},
build: {
target: "esnext",
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rflows"),
lib: {