From 8bd6e61ffc8064b9190249c99edc579a277db866 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 10:47:03 -0700 Subject: [PATCH] feat(rsocials): add campaigns dashboard with workflow thumbnail grid New gallery landing page at /campaigns showing all campaign workflows as cards with miniature SVG previews. Click a card to open the editor at ?workflow=. Editor gains back-link to dashboard and workflow attribute for deep-linking. Co-Authored-By: Claude Opus 4.6 --- .../rsocials/components/campaign-workflow.css | 13 + .../components/folk-campaign-workflow.ts | 11 +- .../components/folk-campaigns-dashboard.ts | 316 ++++++++++++++++++ modules/rsocials/mod.ts | 21 +- vite.config.ts | 25 ++ 5 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 modules/rsocials/components/folk-campaigns-dashboard.ts diff --git a/modules/rsocials/components/campaign-workflow.css b/modules/rsocials/components/campaign-workflow.css index 3308e14..1deff4d 100644 --- a/modules/rsocials/components/campaign-workflow.css +++ b/modules/rsocials/components/campaign-workflow.css @@ -73,6 +73,19 @@ folk-campaign-workflow { border-color: var(--rs-border-strong, #4d4d6c); } +.cw-btn--back { + text-decoration: none; + color: #93c5fd; + border-color: #3b82f644; + display: inline-flex; + align-items: center; + gap: 4px; +} +.cw-btn--back:hover { + background: #3b82f622; + border-color: #3b82f6; +} + .cw-btn--run { background: #3b82f622; border-color: #3b82f655; diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts index 6312c18..839d568 100644 --- a/modules/rsocials/components/folk-campaign-workflow.ts +++ b/modules/rsocials/components/folk-campaign-workflow.ts @@ -81,6 +81,7 @@ class FolkCampaignWorkflow extends HTMLElement { } // Data + private targetWorkflowId = ''; private workflows: CampaignWorkflow[] = []; private currentWorkflowId = ''; private nodes: CampaignWorkflowNode[] = []; @@ -134,8 +135,11 @@ class FolkCampaignWorkflow extends HTMLElement { this.shadow = this.attachShadow({ mode: 'open' }); } + static get observedAttributes() { return ['space', 'workflow']; } + connectedCallback() { this.space = this.getAttribute('space') || 'demo'; + this.targetWorkflowId = this.getAttribute('workflow') || ''; this.initData(); } @@ -155,7 +159,10 @@ class FolkCampaignWorkflow extends HTMLElement { const data = await res.json(); this.workflows = data.results || []; if (this.workflows.length > 0) { - this.loadWorkflow(this.workflows[0]); + const target = this.targetWorkflowId + ? this.workflows.find(w => w.id === this.targetWorkflowId) + : undefined; + this.loadWorkflow(target || this.workflows[0]); } } } catch { @@ -306,7 +313,7 @@ class FolkCampaignWorkflow extends HTMLElement {
- Campaigns + \u2190 Dashboard diff --git a/modules/rsocials/components/folk-campaigns-dashboard.ts b/modules/rsocials/components/folk-campaigns-dashboard.ts new file mode 100644 index 0000000..73787d8 --- /dev/null +++ b/modules/rsocials/components/folk-campaigns-dashboard.ts @@ -0,0 +1,316 @@ +/** + * — Campaign workflow gallery grid. + * + * Fetches all campaign workflows and renders them as cards with miniature + * SVG previews of the node graph. Click a card to open the workflow editor. + * + * Attributes: + * space — space slug (default "demo") + */ + +import { CAMPAIGN_NODE_CATALOG } from '../schemas'; +import type { + CampaignWorkflowNodeDef, + CampaignWorkflowNodeCategory, + CampaignWorkflowNode, + CampaignWorkflowEdge, + CampaignWorkflow, +} from '../schemas'; + +// ── Constants (match folk-campaign-workflow.ts) ── + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 80; + +const CATEGORY_COLORS: Record = { + trigger: '#3b82f6', + delay: '#a855f7', + condition: '#f59e0b', + action: '#10b981', +}; + +function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined { + return CAMPAIGN_NODE_CATALOG.find(n => n.type === type); +} + +function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number { + const def = getNodeDef(node.type); + if (!def) return node.position.y + NODE_HEIGHT / 2; + const ports = direction === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + if (idx === -1) return node.position.y + NODE_HEIGHT / 2; + const spacing = NODE_HEIGHT / (ports.length + 1); + return node.position.y + spacing * (idx + 1); +} + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; +} + +// ── Mini SVG renderer ── + +function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): string { + if (nodes.length === 0) { + return ` + Empty workflow + `; + } + + // Compute bounding box + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of nodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + const pad = 20; + const vx = minX - pad; + const vy = minY - pad; + const vw = maxX - minX + pad * 2; + const vh = maxY - minY + pad * 2; + + // Render edges as Bezier paths + const edgePaths = edges.map(e => { + const fromNode = nodes.find(n => n.id === e.fromNode); + const toNode = nodes.find(n => n.id === e.toNode); + if (!fromNode || !toNode) return ''; + const x1 = fromNode.position.x + NODE_WIDTH; + const y1 = getPortY(fromNode, e.fromPort, 'output'); + const x2 = toNode.position.x; + const y2 = getPortY(toNode, e.toPort, 'input'); + const dx = Math.abs(x2 - x1) * 0.5; + return ``; + }).join(''); + + // Render nodes as rects + const nodeRects = nodes.map(n => { + const def = getNodeDef(n.type); + const cat = def?.category || 'action'; + const color = CATEGORY_COLORS[cat] || '#666'; + const label = def?.icon ? `${def.icon} ${esc(n.label || def.label)}` : esc(n.label || n.type); + // Truncate long labels for mini view + const shortLabel = label.length > 18 ? label.slice(0, 16) + '…' : label; + return ` + + ${shortLabel} + `; + }).join(''); + + return ` + ${edgePaths} + ${nodeRects} + `; +} + +// ── Component ── + +class FolkCampaignsDashboard extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + private workflows: CampaignWorkflow[] = []; + private loading = true; + + private get basePath() { + const host = window.location.hostname; + if (host.endsWith('.rspace.online')) return '/rsocials/'; + return `/${this.space}/rsocials/`; + } + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.render(); + this.loadWorkflows(); + } + + private async loadWorkflows() { + try { + const res = await fetch(`${this.basePath}api/campaign-workflows`); + if (res.ok) { + const data = await res.json(); + this.workflows = data.results || []; + } + } catch { + console.warn('[CampaignsDashboard] Failed to load workflows'); + } + this.loading = false; + this.render(); + } + + private async createWorkflow() { + try { + const res = await fetch(`${this.basePath}api/campaign-workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Campaign Workflow' }), + }); + if (res.ok) { + const wf = await res.json(); + this.navigateToWorkflow(wf.id); + } + } catch { + console.error('[CampaignsDashboard] Failed to create workflow'); + } + } + + private navigateToWorkflow(id: string) { + const url = new URL(window.location.href); + url.searchParams.set('workflow', id); + window.location.href = url.toString(); + } + + private formatDate(ts: number | null): string { + if (!ts) return '—'; + const d = new Date(ts); + const now = Date.now(); + const diff = now - ts; + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + + private render() { + const cards = this.workflows.map(wf => { + const nodeCount = wf.nodes.length; + const statusClass = wf.enabled ? 'cd-badge--enabled' : 'cd-badge--disabled'; + const statusLabel = wf.enabled ? 'Enabled' : 'Disabled'; + const runBadge = wf.lastRunStatus + ? `${wf.lastRunStatus}` + : ''; + + return ` +
+
+ ${renderMiniSVG(wf.nodes, wf.edges)} +
+
+
${esc(wf.name)}
+
+ ${statusLabel} + ${nodeCount} node${nodeCount !== 1 ? 's' : ''} + ${runBadge} + ${wf.runCount > 0 ? `${wf.runCount} run${wf.runCount !== 1 ? 's' : ''}` : ''} +
+
Updated ${this.formatDate(wf.updatedAt)}
+
+
+ `; + }).join(''); + + const emptyState = !this.loading && this.workflows.length === 0 ? ` +
+
📋
+
No campaign workflows yet
+
Create your first workflow to automate social media campaigns
+ +
+ ` : ''; + + const loadingState = this.loading ? ` +
Loading workflows…
+ ` : ''; + + this.shadow.innerHTML = ` + + +
+
+

Campaign Workflows

+ ${!this.loading && this.workflows.length > 0 ? '' : ''} +
+ ${loadingState} + ${emptyState} + ${!this.loading && this.workflows.length > 0 ? `
${cards}
` : ''} +
+ `; + + this.attachListeners(); + } + + private attachListeners() { + // Card clicks + this.shadow.querySelectorAll('.cd-card').forEach(card => { + card.addEventListener('click', () => { + const id = (card as HTMLElement).dataset.wfId; + if (id) this.navigateToWorkflow(id); + }); + }); + + // New workflow buttons + const btnNew = this.shadow.getElementById('btn-new'); + if (btnNew) btnNew.addEventListener('click', () => this.createWorkflow()); + + const btnNewEmpty = this.shadow.querySelector('.cd-btn--new-empty'); + if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createWorkflow()); + } +} + +customElements.define('folk-campaigns-dashboard', FolkCampaignsDashboard); diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 8581885..0c66b45 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -942,15 +942,30 @@ routes.get("/threads", (c) => { routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo"; + const workflowId = c.req.query("workflow"); + + if (workflowId) { + return c.html(renderShell({ + title: `Campaign Workflows — rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); + } + return c.html(renderShell({ title: `Campaign Workflows — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ``, - styles: ``, - scripts: ``, + body: ``, + styles: ``, + scripts: ``, })); }); diff --git a/vite.config.ts b/vite.config.ts index 8864083..0c0bfb2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -924,6 +924,31 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rsocials/campaign-workflow.css"), ); + // Build campaigns dashboard component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + resolve: { + alias: { + "../schemas": resolve(__dirname, "modules/rsocials/schemas.ts"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-campaigns-dashboard.ts"), + formats: ["es"], + fileName: () => "folk-campaigns-dashboard.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-campaigns-dashboard.js", + }, + }, + }, + }); + // Build newsletter manager component await wasmBuild({ configFile: false,