From 5e5fcd768194175956f809140c8745aca3d08422 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 16:13:20 -0400 Subject: [PATCH] feat(rsocials): campaign planner polish Expanded folk-campaign-planner with richer live state, styled dashboard refresh, and schema + mod updates to match the new planner flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rsocials/components/campaign-planner.css | 160 ++++++- .../components/folk-campaign-planner.ts | 417 ++++++++++++++---- .../components/folk-campaign-wizard.ts | 4 +- .../components/folk-campaigns-dashboard.ts | 230 +++++----- modules/rsocials/mod.ts | 220 ++++++++- modules/rsocials/schemas.ts | 12 +- 6 files changed, 821 insertions(+), 222 deletions(-) diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index 13a100fc..a684c1b7 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -1050,14 +1050,158 @@ folk-campaign-planner { filter: drop-shadow(0 0 2px #6366f1); } -/* ── Mobile: brief panel ── */ +/* ── Preview banner (brief-generated nodes awaiting confirmation) ── */ +.cp-preview-banner { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + padding: 10px 16px; + margin: 8px 12px 0; + border-radius: 8px; + background: linear-gradient(135deg, rgba(236, 72, 153, 0.15), rgba(139, 92, 246, 0.15)); + border: 1px solid rgba(236, 72, 153, 0.35); +} +.cp-preview-banner__label { + font-size: 13px; + font-weight: 600; + color: #f9a8d4; +} +.cp-preview-banner__actions { + display: flex; + gap: 6px; +} + +/* ── Preview nodes: dashed outline + pulse ── */ +.cp-node--preview { + animation: cp-preview-pulse 2s ease-in-out infinite; +} +.cp-node--preview foreignObject > div { + box-shadow: 0 0 0 2px #ec4899, 0 0 12px rgba(236, 72, 153, 0.4); + border-radius: 10px; +} +@keyframes cp-preview-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.85; } +} + +/* ── Primary button ── */ +.cp-btn--primary { + background: #6366f1; + color: #fff; + border-color: #6366f1; +} +.cp-btn--primary:hover { + background: #4f46e5; + border-color: #4f46e5; +} + +/* ── Modal overlay + modal (import) ── */ +.cp-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.cp-modal-overlay[hidden] { + display: none; +} +.cp-modal { + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rs-input-border, #334155); + border-radius: 12px; + padding: 1.25rem 1.5rem; + width: 92%; + max-width: 580px; + display: flex; + flex-direction: column; + gap: 0.85rem; + max-height: 90vh; + overflow-y: auto; +} +.cp-modal__header { + display: flex; + align-items: center; + justify-content: space-between; +} +.cp-modal__header h3 { + margin: 0; + font-size: 1.05rem; + color: var(--rs-text-primary, #f1f5f9); +} +.cp-modal__close { + background: none; + border: none; + color: var(--rs-text-muted, #64748b); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; +} +.cp-modal__close:hover { + color: var(--rs-text-primary, #f1f5f9); +} +.cp-modal__hint { + margin: 0; + font-size: 0.8rem; + color: var(--rs-text-secondary, #94a3b8); + line-height: 1.5; +} +.cp-modal__hint code { + background: var(--rs-input-bg, #0f172a); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.75rem; +} +.cp-modal__textarea { + width: 100%; + min-height: 180px; + background: var(--rs-input-bg, #0f172a); + color: var(--rs-input-text, #f1f5f9); + border: 1px solid var(--rs-input-border, #334155); + border-radius: 8px; + padding: 0.7rem; + font-family: inherit; + font-size: 0.85rem; + resize: vertical; + line-height: 1.5; + box-sizing: border-box; +} +.cp-modal__textarea:focus { + outline: none; + border-color: #6366f1; +} +.cp-modal__row { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} +.cp-modal__label { + font-size: 0.8rem; + color: var(--rs-text-secondary, #94a3b8); + font-weight: 600; +} +.cp-modal__select { + background: var(--rs-input-bg, #0f172a); + color: var(--rs-input-text, #f1f5f9); + border: 1px solid var(--rs-input-border, #334155); + border-radius: 6px; + padding: 0.45rem 0.65rem; + font-size: 0.85rem; + min-width: 140px; +} +.cp-modal__select:focus { + outline: none; + border-color: #6366f1; +} + +/* ── Mobile: modal ── */ @media (max-width: 768px) { - .cp-brief-panel.open { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 20; + .cp-modal { + width: 95%; } } diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index c0110042..646c8d5c 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -23,6 +23,7 @@ import type { GoalNodeData, MessageNodeData, ToneNodeData, + BriefNodeData, } from '../schemas'; import { SocialsLocalFirstClient } from '../local-first-client'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; @@ -68,6 +69,9 @@ const CAMPAIGN_PORT_DEFS: Record = { tone: [ { kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] }, ], + brief: [ + { kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#ec4899', connectsTo: ['feeds-in', 'sequence-in'] }, + ], }; // ── Edge type → visual config ── @@ -101,6 +105,7 @@ function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } { case 'goal': return { w: 240, h: 130 }; case 'message': return { w: 220, h: 100 }; case 'tone': return { w: 220, h: 110 }; + case 'brief': return { w: 260, h: 150 }; default: return { w: 200, h: 100 }; } } @@ -198,11 +203,21 @@ class FolkCampaignPlanner extends HTMLElement { // Postiz private postizOpen = false; - // Brief panel - private briefPanelOpen = false; - private briefText = ''; - private briefPlatforms: string[] = ['x', 'linkedin', 'instagram', 'threads', 'bluesky']; - private briefLoading = false; + // Brief node generation state (keyed by node id) + private briefLoading = new Set(); + + // Preview state for brief-generated nodes (not yet persisted) + private previewNodeIds = new Set(); + private previewEdgeIds = new Set(); + private previewSourceBriefId = ''; + + // Import modal state + private importModalOpen = false; + private importText = ''; + private importPlatform = 'x'; + + // Explicit flow id requested via attribute (overrides active flow) + private requestedFlowId = ''; // Persistence private localFirstClient: SocialsLocalFirstClient | null = null; @@ -230,6 +245,7 @@ class FolkCampaignPlanner extends HTMLElement { connectedCallback() { this.space = this.getAttribute('space') || 'demo'; + this.requestedFlowId = this.getAttribute('flow-id') || ''; this.initData(); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' })); } @@ -256,8 +272,17 @@ class FolkCampaignPlanner extends HTMLElement { if (!this.currentFlowId || this.saveTimer) return; const flow = doc.campaignFlows?.[this.currentFlowId]; if (flow) { - this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })); - this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); + // Preserve uncommitted preview nodes/edges when applying remote updates + const previewNodes = this.nodes.filter(n => this.previewNodeIds.has(n.id)); + const previewEdges = this.edges.filter(e => this.previewEdgeIds.has(e.id)); + this.nodes = [ + ...flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + ...previewNodes, + ]; + this.edges = [ + ...flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })), + ...previewEdges, + ]; if (this.currentView === 'canvas') { this.drawCanvasContent(); } else { @@ -269,7 +294,9 @@ class FolkCampaignPlanner extends HTMLElement { const activeId = this.localFirstClient.getActiveFlowId(); const flows = this.localFirstClient.listCampaignFlows(); - if (activeId && this.localFirstClient.getCampaignFlow(activeId)) { + if (this.requestedFlowId && this.localFirstClient.getCampaignFlow(this.requestedFlowId)) { + this.loadFlow(this.requestedFlowId); + } else if (activeId && this.localFirstClient.getCampaignFlow(activeId)) { this.loadFlow(activeId); } else if (flows.length > 0) { this.loadFlow(flows[0].id); @@ -315,7 +342,14 @@ class FolkCampaignPlanner extends HTMLElement { private executeSave() { if (this.localFirstClient && this.currentFlowId) { - this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges); + // Exclude preview-only nodes/edges from persistence until confirmed + const committedNodes = this.previewNodeIds.size > 0 + ? this.nodes.filter(n => !this.previewNodeIds.has(n.id)) + : this.nodes; + const committedEdges = this.previewEdgeIds.size > 0 + ? this.edges.filter(e => !this.previewEdgeIds.has(e.id)) + : this.edges; + this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, committedNodes, committedEdges); } else if (this.currentFlowId) { localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({ id: this.currentFlowId, name: this.flowName, @@ -662,6 +696,9 @@ class FolkCampaignPlanner extends HTMLElement { case 'tone': data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' }; break; + case 'brief': + data = { label: 'Campaign Brief', text: '', platforms: ['x', 'linkedin', 'instagram', 'threads', 'bluesky'] }; + break; } const node: CampaignPlannerNode = { id, type, position: { x, y }, data }; this.nodes.push(node); @@ -709,7 +746,7 @@ class FolkCampaignPlanner extends HTMLElement { overlay.classList.add('inline-edit-overlay'); const panelW = 260; - const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : 220; + const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : node.type === 'brief' ? 400 : 220; const panelX = s.w + 12; const panelY = 0; @@ -893,6 +930,28 @@ class FolkCampaignPlanner extends HTMLElement { `; break; } + case 'brief': { + const d = node.data as BriefNodeData; + const loading = this.briefLoading.has(node.id); + const plats = d.platforms || []; + body = ` +
+ + + +
+ ${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p => + `` + ).join('')} +
+ + ${d.lastGeneratedAt ? `
Last generated ${new Date(d.lastGeneratedAt).toLocaleString()}
` : ''} +
`; + break; + } } // AI fill button for post/thread nodes @@ -952,8 +1011,15 @@ class FolkCampaignPlanner extends HTMLElement { if (field === 'text' && node.type === 'message') { (node.data as MessageNodeData).label = val.substring(0, 40); } + if (field === 'text' && node.type === 'brief') { + const firstLine = val.split('\n')[0].substring(0, 40).trim(); + (node.data as BriefNodeData).label = firstLine || 'Campaign Brief'; + // Live-toggle the Generate button disabled state + const genBtn = panel.querySelector('[data-action="generate-brief"]') as HTMLButtonElement | null; + if (genBtn && !this.briefLoading.has(node.id)) genBtn.disabled = !val.trim(); + } // Mark downstream nodes stale when input nodes change - if (node.type === 'goal' || node.type === 'message' || node.type === 'tone') { + if (node.type === 'goal' || node.type === 'message' || node.type === 'tone' || node.type === 'brief') { this.markDownstreamStale(node.id); } this.scheduleSave(); @@ -962,6 +1028,19 @@ class FolkCampaignPlanner extends HTMLElement { el.addEventListener('change', handler); }); + // Brief platform checkboxes + panel.querySelectorAll('[data-brief-platform]').forEach(cb => { + cb.addEventListener('change', () => { + const picked: string[] = []; + panel.querySelectorAll('[data-brief-platform]:checked').forEach(c => { + const name = (c as HTMLInputElement).getAttribute('data-brief-platform'); + if (name) picked.push(name); + }); + (node.data as BriefNodeData).platforms = picked; + this.scheduleSave(); + }); + }); + // Actions panel.querySelectorAll('[data-action]').forEach(el => { el.addEventListener('click', () => { @@ -979,6 +1058,8 @@ class FolkCampaignPlanner extends HTMLElement { } } else if (action === 'ai-fill') { this.aiFillNode(node.id); + } else if (action === 'generate-brief') { + this.generateFromBriefNode(node.id); } }); }); @@ -994,6 +1075,7 @@ class FolkCampaignPlanner extends HTMLElement { case 'goal': return '🎯'; case 'message': return '💬'; case 'tone': return '🎭'; + case 'brief': return ''; default: return ''; } } @@ -1397,7 +1479,8 @@ class FolkCampaignPlanner extends HTMLElement {
- + + @@ -1406,27 +1489,18 @@ class FolkCampaignPlanner extends HTMLElement {
-
-
-
- \u2728 Generate from Brief - -
-
- - - -
- ${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p => - `` - ).join('')} -
- + ${this.previewNodeIds.size > 0 ? ` +
+ \u2728 AI-generated preview \u2014 ${this.previewNodeIds.size} node${this.previewNodeIds.size === 1 ? '' : 's'} +
+ + +
+ ` : ''} + +
@@ -1457,6 +1531,26 @@ class FolkCampaignPlanner extends HTMLElement {
+ +
+
+
+

Import Posts from Markdown

+ +
+

Paste tweets or posts separated by --- on its own line. Each becomes a post node wired to the selected platform.

+ +
+ + + +
+
+
`; @@ -1503,12 +1597,14 @@ class FolkCampaignPlanner extends HTMLElement { case 'goal': inner = this.renderGoalNodeInner(node); break; case 'message': inner = this.renderMessageNodeInner(node); break; case 'tone': inner = this.renderToneNodeInner(node); break; + case 'brief': inner = this.renderBriefNodeInner(node); break; } const ports = this.renderPortsSvg(node); + const previewClass = this.previewNodeIds.has(node.id) ? ' cp-node--preview' : ''; return ` - + ${inner} @@ -1629,6 +1725,27 @@ class FolkCampaignPlanner extends HTMLElement {
`; } + private renderBriefNodeInner(node: CampaignPlannerNode): string { + const d = node.data as BriefNodeData; + const loading = this.briefLoading.has(node.id); + const plats = d.platforms || []; + const preview = (d.text || '').split('\n')[0].substring(0, 80); + const platPills = plats.slice(0, 4).map(p => `${p}`).join(''); + const extra = plats.length > 4 ? `+${plats.length - 4}` : ''; + return `
+
+
+
+ + ${esc(d.label || 'Campaign Brief')} + ${loading ? 'generating…' : ''} +
+
${esc(preview) || 'Click to write a brief'}
+
${platPills}${extra}
+
+
`; + } + private renderToneNodeInner(node: CampaignPlannerNode): string { const d = node.data as ToneNodeData; return `
@@ -1670,20 +1787,29 @@ class FolkCampaignPlanner extends HTMLElement { return this.nodes.filter(n => n.stale).length; } - // ── Brief panel methods ── + // ── Brief-node generation ── - private async generateFromBrief() { - if (this.briefLoading || !this.briefText.trim()) return; - this.briefLoading = true; - this.updateBriefPanel(); + private async generateFromBriefNode(briefNodeId: string, options?: { discardPrevious?: boolean }) { + const briefNode = this.nodes.find(n => n.id === briefNodeId); + if (!briefNode || briefNode.type !== 'brief') return; + if (this.briefLoading.has(briefNodeId)) return; + const d = briefNode.data as BriefNodeData; + if (!d.text.trim()) return; + + if (options?.discardPrevious) this.discardPreviewInternal(); + + this.briefLoading.add(briefNodeId); + this.exitInlineEdit(); + this.enterInlineEdit(briefNodeId); + this.drawCanvasContent(); try { const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - rawBrief: this.briefText, - platforms: this.briefPlatforms, + rawBrief: d.text, + platforms: d.platforms, }), }); @@ -1693,22 +1819,167 @@ class FolkCampaignPlanner extends HTMLElement { } const flow: CampaignFlow = await res.json(); - this.currentFlowId = flow.id; - this.flowName = flow.name; - this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })); - this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); - this.briefPanelOpen = false; + // Offset generated nodes to the right of the brief so the brief stays visible + const offsetX = briefNode.position.x + 300; + const offsetY = briefNode.position.y; + const generated = flow.nodes.map(n => ({ + ...n, + position: { x: n.position.x + offsetX, y: n.position.y + offsetY }, + data: { ...n.data }, + })); + d.lastGeneratedAt = Date.now(); + + // brief → first goal (if present) + const generatedEdges: CampaignEdge[] = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); + const firstGoal = generated.find(n => n.type === 'goal'); + if (firstGoal) { + generatedEdges.push({ + id: `e-brief-${Date.now()}-goal`, + from: briefNode.id, + to: firstGoal.id, + type: 'feeds', + }); + } + + // Append generated nodes/edges to the CURRENT flow as preview (not persisted) + this.previewNodeIds = new Set(generated.map(n => n.id)); + this.previewEdgeIds = new Set(generatedEdges.map(e => e.id)); + this.previewSourceBriefId = briefNode.id; + + this.nodes = [...this.nodes, ...generated]; + this.edges = [...this.edges, ...generatedEdges]; + + this.exitInlineEdit(); this.render(); requestAnimationFrame(() => this.fitView()); } catch (e: any) { console.error('[CampaignPlanner] Generate from brief error:', e.message); alert('Failed to generate: ' + e.message); } finally { - this.briefLoading = false; + this.briefLoading.delete(briefNodeId); + // Re-render to clear loading state in the brief node + this.drawCanvasContent(); } } + // ── Preview actions ── + + private confirmPreview() { + if (this.previewNodeIds.size === 0) return; + this.previewNodeIds.clear(); + this.previewEdgeIds.clear(); + this.previewSourceBriefId = ''; + // Persist immediately (not debounced) + if (this.localFirstClient && this.currentFlowId) { + this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges); + } + this.render(); + } + + private discardPreviewInternal() { + if (this.previewNodeIds.size === 0 && this.previewEdgeIds.size === 0) return; + this.nodes = this.nodes.filter(n => !this.previewNodeIds.has(n.id)); + this.edges = this.edges.filter(e => !this.previewEdgeIds.has(e.id)); + this.previewNodeIds.clear(); + this.previewEdgeIds.clear(); + this.previewSourceBriefId = ''; + } + + private discardPreview() { + this.discardPreviewInternal(); + this.render(); + } + + private regeneratePreview() { + const briefId = this.previewSourceBriefId; + if (!briefId) return; + this.generateFromBriefNode(briefId, { discardPrevious: true }); + } + + // ── Markdown import ── + + private openImportModal() { + this.importModalOpen = true; + this.render(); + } + + private closeImportModal() { + this.importModalOpen = false; + this.render(); + } + + private importFromMarkdown() { + const raw = this.importText || ''; + const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (tweets.length === 0) { + alert('No posts to import — separate tweets with lines containing just ---'); + return; + } + + const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null; + const rect = svg?.getBoundingClientRect(); + const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 300; + const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 200; + + const platform = this.importPlatform || 'x'; + + // Find or create a platform node for this platform + let platformNode = this.nodes.find(n => + n.type === 'platform' && (n.data as PlatformNodeData).platform === platform + ); + const newNodes: CampaignPlannerNode[] = []; + const newEdges: CampaignEdge[] = []; + const now = Date.now(); + + if (!platformNode) { + const platId = `platform-${now}-${platform}`; + platformNode = { + id: platId, + type: 'platform', + position: { x: cx + 600, y: cy }, + data: { label: platform.charAt(0).toUpperCase() + platform.slice(1), platform, handle: '' } as PlatformNodeData, + }; + newNodes.push(platformNode); + } + + // Grid-position imported posts in a 3-wide column + tweets.forEach((text, i) => { + const postId = `post-${now}-${i}`; + const col = i % 3; + const row = Math.floor(i / 3); + newNodes.push({ + id: postId, + type: 'post', + position: { x: cx + col * 260, y: cy + row * 140 }, + data: { + label: text.split('\n')[0].substring(0, 40) || 'Imported post', + platform, + postType: 'text', + content: text, + scheduledAt: '', + status: 'draft', + hashtags: [], + } as PostNodeData, + }); + newEdges.push({ + id: `e-import-${now}-${i}`, + from: postId, + to: platformNode!.id, + type: 'publish', + }); + }); + + this.nodes = [...this.nodes, ...newNodes]; + this.edges = [...this.edges, ...newEdges]; + + this.importText = ''; + this.importModalOpen = false; + this.render(); + this.scheduleSave(); + requestAnimationFrame(() => this.fitView()); + } + private async regenStaleNodes() { const staleIds = this.nodes.filter(n => n.stale).map(n => n.id); if (staleIds.length === 0) return; @@ -1773,15 +2044,6 @@ class FolkCampaignPlanner extends HTMLElement { } } - private updateBriefPanel() { - const panel = this.shadow.getElementById('brief-panel'); - const genBtn = panel?.querySelector('#brief-generate') as HTMLButtonElement | null; - if (genBtn) { - genBtn.disabled = this.briefLoading; - genBtn.textContent = this.briefLoading ? 'Generating...' : 'Generate Graph'; - } - } - private updateToolbarStaleCount() { const btn = this.shadow.getElementById('regen-stale-btn'); const count = this.getStaleCount(); @@ -1933,33 +2195,34 @@ class FolkCampaignPlanner extends HTMLElement { const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom; this.addNode('audience', cx, cy); }); + this.shadow.getElementById('add-brief')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom; + const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom; + this.addNode('brief', cx, cy); + // Auto-open inline edit so user can start typing + const lastNode = this.nodes[this.nodes.length - 1]; + if (lastNode) setTimeout(() => this.enterInlineEdit(lastNode.id), 50); + }); - // Brief panel - this.shadow.getElementById('toggle-brief')?.addEventListener('click', () => { - this.briefPanelOpen = !this.briefPanelOpen; - const panel = this.shadow.getElementById('brief-panel'); - if (panel) panel.classList.toggle('open', this.briefPanelOpen); + // Import modal + this.shadow.getElementById('open-import')?.addEventListener('click', () => this.openImportModal()); + this.shadow.getElementById('import-close')?.addEventListener('click', () => this.closeImportModal()); + this.shadow.getElementById('import-modal')?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).id === 'import-modal') this.closeImportModal(); }); - this.shadow.getElementById('close-brief')?.addEventListener('click', () => { - this.briefPanelOpen = false; - const panel = this.shadow.getElementById('brief-panel'); - if (panel) panel.classList.remove('open'); + this.shadow.getElementById('import-text')?.addEventListener('input', (e) => { + this.importText = (e.target as HTMLTextAreaElement).value; }); - this.shadow.getElementById('brief-text')?.addEventListener('input', (e) => { - this.briefText = (e.target as HTMLTextAreaElement).value; - }); - this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]').forEach(cb => { - cb.addEventListener('change', () => { - const checked: string[] = []; - this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]:checked').forEach(c => { - checked.push((c as HTMLInputElement).value); - }); - this.briefPlatforms = checked; - }); - }); - this.shadow.getElementById('brief-generate')?.addEventListener('click', () => { - this.generateFromBrief(); + this.shadow.getElementById('import-platform')?.addEventListener('change', (e) => { + this.importPlatform = (e.target as HTMLSelectElement).value; }); + this.shadow.getElementById('import-submit')?.addEventListener('click', () => this.importFromMarkdown()); + + // Preview banner actions + this.shadow.getElementById('preview-keep')?.addEventListener('click', () => this.confirmPreview()); + this.shadow.getElementById('preview-discard')?.addEventListener('click', () => this.discardPreview()); + this.shadow.getElementById('preview-regen')?.addEventListener('click', () => this.regeneratePreview()); // Regen stale this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => { diff --git a/modules/rsocials/components/folk-campaign-wizard.ts b/modules/rsocials/components/folk-campaign-wizard.ts index d12abefd..8f4f04db 100644 --- a/modules/rsocials/components/folk-campaign-wizard.ts +++ b/modules/rsocials/components/folk-campaign-wizard.ts @@ -604,9 +604,9 @@ export class FolkCampaignWizard extends HTMLElement {
` : result.threadIds?.length ? `

${result.threadIds.length} thread(s) created

` : ''} ${result.newsletters?.length ? `

${result.newsletters.length} newsletter draft(s)

` : ''}
diff --git a/modules/rsocials/components/folk-campaigns-dashboard.ts b/modules/rsocials/components/folk-campaigns-dashboard.ts index 9ff753a2..49f0e6ef 100644 --- a/modules/rsocials/components/folk-campaigns-dashboard.ts +++ b/modules/rsocials/components/folk-campaigns-dashboard.ts @@ -1,8 +1,9 @@ /** - * — Campaign workflow gallery grid. + * — Campaign 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. + * Lists all campaign flows and renders mini SVG previews. Click a card to + * open that flow in the planner. Also surfaces the AI wizard (which sets up + * a new flow) and a blank "+ New" affordance. * * Attributes: * space — space slug (default "demo") @@ -10,39 +11,43 @@ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; -import { CAMPAIGN_NODE_CATALOG } from '../schemas'; import type { - CampaignWorkflowNodeDef, - CampaignWorkflowNodeCategory, - CampaignWorkflowNode, - CampaignWorkflowEdge, - CampaignWorkflow, + CampaignFlow, + CampaignPlannerNode, + CampaignEdge, + PhaseNodeData, } from '../schemas'; -// ── Constants (match folk-campaign-workflow.ts) ── +// ── Mini SVG constants ── -const NODE_WIDTH = 220; -const NODE_HEIGHT = 80; - -const CATEGORY_COLORS: Record = { - trigger: '#3b82f6', - delay: '#a855f7', - condition: '#f59e0b', - action: '#10b981', +const NODE_COLORS: Record = { + post: '#3b82f6', + thread: '#8b5cf6', + platform: '#10b981', + audience: '#f59e0b', + phase: '#64748b', + goal: '#6366f1', + message: '#6366f1', + tone: '#8b5cf6', + brief: '#ec4899', }; -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 nodeSize(n: CampaignPlannerNode): { w: number; h: number } { + switch (n.type) { + case 'post': return { w: 240, h: 120 }; + case 'thread': return { w: 240, h: 100 }; + case 'platform': return { w: 180, h: 80 }; + case 'audience': return { w: 180, h: 80 }; + case 'phase': { + const d = n.data as PhaseNodeData; + return { w: d.size?.w || 400, h: d.size?.h || 300 }; + } + case 'goal': return { w: 240, h: 130 }; + case 'message': return { w: 220, h: 100 }; + case 'tone': return { w: 220, h: 110 }; + case 'brief': return { w: 260, h: 150 }; + default: return { w: 200, h: 100 }; + } } function esc(s: string): string { @@ -53,78 +58,81 @@ function esc(s: string): string { // ── Mini SVG renderer ── -function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): string { +function renderMiniFlowSVG(nodes: CampaignPlannerNode[], edges: CampaignEdge[]): string { if (nodes.length === 0) { return ` - Empty workflow + Empty flow `; } - // Compute bounding box let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const n of nodes) { + const s = nodeSize(n); 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); + maxX = Math.max(maxX, n.position.x + s.w); + maxY = Math.max(maxY, n.position.y + s.h); } - const pad = 20; + const pad = 40; const vx = minX - pad; const vy = minY - pad; - const vw = maxX - minX + pad * 2; - const vh = maxY - minY + pad * 2; + const vw = (maxX - minX) + pad * 2; + const vh = (maxY - minY) + pad * 2; - // Render edges as Bezier paths + // Edges (simple line midpoint-to-midpoint; kept light) 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 from = nodes.find(n => n.id === e.from); + const to = nodes.find(n => n.id === e.to); + if (!from || !to) return ''; + const fs = nodeSize(from); + const ts = nodeSize(to); + const x1 = from.position.x + fs.w; + const y1 = from.position.y + fs.h / 2; + const x2 = to.position.x; + const y2 = to.position.y + ts.h / 2; const dx = Math.abs(x2 - x1) * 0.5; - return ``; + 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} - `; + // Phase rects behind + const phaseRects = nodes.filter(n => n.type === 'phase').map(n => { + const s = nodeSize(n); + const d = n.data as PhaseNodeData; + return ``; + }).join(''); + + // Content nodes as solid rects + const contentRects = nodes.filter(n => n.type !== 'phase').map(n => { + const s = nodeSize(n); + const color = NODE_COLORS[n.type] || '#666'; + return ``; }).join(''); return ` + ${phaseRects} ${edgePaths} - ${nodeRects} + ${contentRects} `; } // ── Component ── const DASHBOARD_TOUR_STEPS: TourStep[] = [ - { target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign creation flow — answer a few questions and get a ready-to-run workflow.' }, - { target: '#btn-new', title: 'New Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' }, - { target: '.cd-card', title: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' }, + { target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign setup — answer a few questions and the wizard builds the flow for you.' }, + { target: '#btn-new', title: 'New Campaign', message: 'Start a blank campaign flow and lay out your own posts, platforms, and audiences.' }, + { target: '.cd-card', title: 'Campaign Cards', message: 'Click any card to open its flow in the planner.' }, ]; class FolkCampaignsDashboard extends HTMLElement { private shadow: ShadowRoot; private space = ''; - private workflows: CampaignWorkflow[] = []; + private flows: CampaignFlow[] = []; private loading = true; private _tour!: TourEngine; private get basePath() { const host = window.location.hostname; - if (host.endsWith('.rspace.online')) return '/rsocials/'; + if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) return '/rsocials/'; return `/${this.space}/rsocials/`; } @@ -142,18 +150,18 @@ class FolkCampaignsDashboard extends HTMLElement { connectedCallback() { this.space = this.getAttribute('space') || 'demo'; this.render(); - this.loadWorkflows(); + this.loadFlows(); } - private async loadWorkflows() { + private async loadFlows() { try { - const res = await fetch(`${this.basePath}api/campaign-workflows`); + const res = await fetch(`${this.basePath}api/campaign/flows`); if (res.ok) { const data = await res.json(); - this.workflows = data.results || []; + this.flows = data.results || []; } } catch { - console.warn('[CampaignsDashboard] Failed to load workflows'); + console.warn('[CampaignsDashboard] Failed to load flows'); } this.loading = false; this.render(); @@ -164,26 +172,24 @@ class FolkCampaignsDashboard extends HTMLElement { startTour() { this._tour.start(); } - private async createWorkflow() { + private async createFlow() { try { - const res = await fetch(`${this.basePath}api/campaign-workflows`, { + const res = await fetch(`${this.basePath}api/campaign/flows`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'New Campaign Workflow' }), + body: JSON.stringify({ name: 'New Campaign' }), }); if (res.ok) { - const wf = await res.json(); - this.navigateToWorkflow(wf.id); + const flow = await res.json(); + this.navigateToFlow(flow.id); } } catch { - console.error('[CampaignsDashboard] Failed to create workflow'); + console.error('[CampaignsDashboard] Failed to create flow'); } } - private navigateToWorkflow(id: string) { - const url = new URL(window.location.href); - url.searchParams.set('workflow', id); - window.location.href = url.toString(); + private navigateToFlow(id: string) { + window.location.href = `${this.basePath}campaign-flow?id=${encodeURIComponent(id)}`; } private formatDate(ts: number | null): string { @@ -198,47 +204,43 @@ class FolkCampaignsDashboard extends HTMLElement { } 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}` - : ''; + const cards = this.flows.map(flow => { + const nodeCount = flow.nodes.length; + const postCount = flow.nodes.filter(n => n.type === 'post').length; + const platformCount = flow.nodes.filter(n => n.type === 'platform').length; return ` -
+
- ${renderMiniSVG(wf.nodes, wf.edges)} + ${renderMiniFlowSVG(flow.nodes, flow.edges)}
-
${esc(wf.name)}
+
${esc(flow.name)}
- ${statusLabel} ${nodeCount} node${nodeCount !== 1 ? 's' : ''} - ${runBadge} - ${wf.runCount > 0 ? `${wf.runCount} run${wf.runCount !== 1 ? 's' : ''}` : ''} + ${postCount > 0 ? `${postCount} post${postCount !== 1 ? 's' : ''}` : ''} + ${platformCount > 0 ? `${platformCount} platform${platformCount !== 1 ? 's' : ''}` : ''}
-
Updated ${this.formatDate(wf.updatedAt)}
+
Updated ${this.formatDate(flow.updatedAt)}
`; }).join(''); - const emptyState = !this.loading && this.workflows.length === 0 ? ` + const emptyState = !this.loading && this.flows.length === 0 ? `
-
📋
-
No campaign workflows yet
-
Use the AI wizard for guided campaign creation, or start a blank workflow
+
📢
+
No campaigns yet
+
Use the AI wizard to set up a flow from a brief, or start a blank canvas
- +
` : ''; const loadingState = this.loading ? ` -
Loading workflows…
+
Loading campaigns…
` : ''; this.shadow.innerHTML = ` @@ -249,6 +251,7 @@ class FolkCampaignsDashboard extends HTMLElement { .cd-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; } .cd-header h2 { margin: 0; font-size: 1.4rem; font-weight: 600; } + .cd-header__subtitle { font-size: 0.85rem; color: var(--rs-text-muted, #888); margin-top: 0.25rem; } .cd-btn { border: none; border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; @@ -289,12 +292,9 @@ class FolkCampaignsDashboard extends HTMLElement { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 500; text-transform: capitalize; } - .cd-badge--enabled { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); } - .cd-badge--disabled { background: var(--rs-bg-surface-raised, #3f3f46); color: var(--rs-text-muted, #a1a1aa); } - .cd-badge--success { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); } - .cd-badge--error { background: rgba(239, 68, 68, 0.15); color: var(--rs-error, #fca5a5); } .cd-badge--nodes { background: rgba(59, 130, 246, 0.15); color: var(--rs-primary, #93c5fd); } - .cd-badge--runs { background: rgba(99, 102, 241, 0.15); color: var(--rs-primary-hover, #c4b5fd); } + .cd-badge--posts { background: rgba(59, 130, 246, 0.15); color: #93c5fd; } + .cd-badge--platforms { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; } .cd-card__date { font-size: 0.75rem; color: var(--rs-text-muted, #888); } @@ -312,16 +312,19 @@ class FolkCampaignsDashboard extends HTMLElement {
-

Campaign Workflows

+
+

Campaigns

+
Plan, wire up, and schedule multi-platform campaigns
+
- ${!this.loading && this.workflows.length > 0 ? '' : ''} + ${!this.loading && this.flows.length > 0 ? '' : ''}
${loadingState} ${emptyState} - ${!this.loading && this.workflows.length > 0 ? `
${cards}
` : ''} + ${!this.loading && this.flows.length > 0 ? `
${cards}
` : ''}
`; @@ -330,22 +333,19 @@ class FolkCampaignsDashboard extends HTMLElement { } 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); + const id = (card as HTMLElement).dataset.flowId; + if (id) this.navigateToFlow(id); }); }); - // New workflow buttons const btnNew = this.shadow.getElementById('btn-new'); - if (btnNew) btnNew.addEventListener('click', () => this.createWorkflow()); + if (btnNew) btnNew.addEventListener('click', () => this.createFlow()); const btnNewEmpty = this.shadow.querySelector('.cd-btn--new-empty:not(#btn-wizard-empty)'); - if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createWorkflow()); + if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createFlow()); - // Wizard buttons const wizardUrl = `${this.basePath}campaign-wizard`; const btnWizard = this.shadow.getElementById('btn-wizard'); if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; }); diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 63822fa7..2d468d6c 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -992,6 +992,64 @@ Rules: } }); +// ── Campaign Flow CRUD API (Automerge-backed, powers the dashboard) ── + +routes.get("/api/campaign/flows", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const flows = Object.values(doc.campaignFlows || {}); + flows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + return c.json({ count: flows.length, results: flows }); +}); + +routes.post("/api/campaign/flows", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json().catch(() => ({})); + + const docId = socialsDocId(dataSpace); + ensureDoc(dataSpace); + const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + + const flow: CampaignFlow = { + id: flowId, + name: body.name || "New Campaign", + nodes: body.nodes || [], + edges: body.edges || [], + createdAt: now, + updatedAt: now, + createdBy: null, + }; + + _syncServer!.changeDoc(docId, `create campaign flow ${flowId}`, (d) => { + if (!d.campaignFlows) d.campaignFlows = {} as any; + (d.campaignFlows as any)[flowId] = flow; + d.activeFlowId = flowId; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.campaignFlows[flowId], 201); +}); + +routes.delete("/api/campaign/flows/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = socialsDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.campaignFlows?.[id]) return c.json({ error: "Campaign flow not found" }, 404); + + _syncServer!.changeDoc(docId, `delete campaign flow ${id}`, (d) => { + delete d.campaignFlows[id]; + if (d.activeFlowId === id) d.activeFlowId = ''; + }); + + return c.json({ ok: true }); +}); + // ── Campaign Workflow CRUD API ── routes.get("/api/campaign-workflows", (c) => { @@ -1875,7 +1933,145 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => { (d.campaignWorkflows as any)[wfId] = workflow; }); - // 6. Mark wizard as committed + // 6. Build a CampaignFlow from the same data so the planner can open it + const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 6)}`; + const flowNodes: CampaignPlannerNode[] = []; + const flowEdges: CampaignEdge[] = []; + const brief = wizard.extractedBrief; + + // Input column (x=50): goal, messages, tone + const goalNodeId = `goal-${now}`; + flowNodes.push({ + id: goalNodeId, type: 'goal', + position: { x: 50, y: 50 }, + data: { + label: campaign.title || 'Campaign Goal', + objective: campaign.description || brief?.audience || '', + startDate: brief?.startDate || '', + endDate: brief?.endDate || '', + } as GoalNodeData, + }); + + const msgIds: string[] = []; + (brief?.keyMessages || []).forEach((msg, i) => { + const mid = `message-${now}-${i}`; + msgIds.push(mid); + flowNodes.push({ + id: mid, type: 'message', + position: { x: 50, y: 220 + i * 130 }, + data: { + label: msg.substring(0, 40), + text: msg, + priority: i === 0 ? 'high' : 'medium', + } as MessageNodeData, + }); + }); + + const toneNodeId = `tone-${now}`; + flowNodes.push({ + id: toneNodeId, type: 'tone', + position: { x: 50, y: 220 + (brief?.keyMessages?.length || 0) * 130 }, + data: { + label: `${brief?.tone || 'Professional'} tone`, + tone: brief?.tone || 'professional', + style: brief?.style || 'awareness', + audience: brief?.audience || '', + sizeEstimate: '', + } as ToneNodeData, + }); + + // Phase column (x=350) + const phaseIds: string[] = []; + campaign.phases.forEach((phase, pi) => { + const pid = `phase-${now}-${pi}`; + phaseIds.push(pid); + const phaseColors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; + flowNodes.push({ + id: pid, type: 'phase', + position: { x: 350, y: 50 + pi * 320 }, + data: { + label: phase.label || `Phase ${pi + 1}`, + dateRange: phase.days || '', + color: phaseColors[pi % phaseColors.length], + progress: 0, + childNodeIds: [], + size: { w: 400, h: 280 }, + } as PhaseNodeData, + }); + flowEdges.push({ + id: `e-goal-phase-${pi}`, + from: goalNodeId, to: pid, type: 'feeds', + }); + }); + + // Post + platform columns + const platformMap = new Map(); + campaign.posts.forEach((post, pi) => { + const postNodeId = `post-${now}-${pi}`; + const phaseIdx = Math.max(0, (post.phase || 1) - 1); + const phaseY = 50 + phaseIdx * 320; + flowNodes.push({ + id: postNodeId, type: 'post', + position: { x: 400 + (pi % 3) * 260, y: phaseY + 60 + Math.floor(pi / 3) * 140 }, + data: { + label: (post.content || '').split('\n')[0].substring(0, 40) || post.platform, + platform: post.platform || 'x', + postType: post.postType || 'text', + content: post.content || '', + scheduledAt: post.scheduledAt || '', + status: (post.status as any) || 'draft', + hashtags: post.hashtags || [], + } as PostNodeData, + }); + if (msgIds.length > 0) { + flowEdges.push({ + id: `e-msg-post-${pi}`, + from: msgIds[pi % msgIds.length], to: postNodeId, type: 'feeds', + }); + } + flowEdges.push({ + id: `e-tone-post-${pi}`, + from: toneNodeId, to: postNodeId, type: 'feeds', + }); + + const plat = post.platform || 'x'; + if (!platformMap.has(plat)) { + const platId = `platform-${now}-${plat}`; + platformMap.set(plat, platId); + const platIdx = platformMap.size - 1; + flowNodes.push({ + id: platId, type: 'platform', + position: { x: 950, y: 50 + platIdx * 120 }, + data: { + label: plat.charAt(0).toUpperCase() + plat.slice(1), + platform: plat, + handle: '', + } as PlatformNodeData, + }); + } + flowEdges.push({ + id: `e-post-plat-${pi}`, + from: postNodeId, to: platformMap.get(plat)!, type: 'publish', + }); + }); + + const flow: CampaignFlow = { + id: flowId, + name: campaign.title, + nodes: flowNodes, + edges: flowEdges, + createdAt: now, + updatedAt: now, + createdBy: null, + }; + + _syncServer!.changeDoc(docId, `wizard ${id} create flow`, (d) => { + if (!d.campaignFlows) d.campaignFlows = {} as any; + (d.campaignFlows as any)[flowId] = flow; + d.activeFlowId = flowId; + }); + + // 7. Mark wizard as committed _syncServer!.changeDoc(docId, `wizard ${id} → committed`, (d) => { const w = d.campaignWizards?.[id]; if (!w) return; @@ -1887,6 +2083,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => { return c.json({ ok: true, campaignId: campaign.id, + flowId, threadIds, threads: threadInfos, workflowId: wfId, @@ -2308,13 +2505,15 @@ Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goa routes.get("/campaign-flow", (c) => { const space = c.req.param("space") || "demo"; + const flowId = c.req.query("id") || ""; + const idAttr = flowId ? ` flow-id="${escapeHtml(flowId)}"` : ""; return c.html(renderShell({ title: `Campaign Flow — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ``, + body: ``, styles: ``, scripts: ``, })); @@ -2462,23 +2661,8 @@ 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`, + title: `Campaigns — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index a40133f8..d7166003 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -56,7 +56,7 @@ export interface Campaign { // ── Campaign planner (flow canvas) types ── -export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone'; +export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone' | 'brief'; export interface PostNodeData { label: string; @@ -118,11 +118,19 @@ export interface ToneNodeData { sizeEstimate: string; } +export interface BriefNodeData { + label: string; + text: string; + platforms: string[]; + generating?: boolean; + lastGeneratedAt?: number; +} + export interface CampaignPlannerNode { id: string; type: CampaignNodeType; position: { x: number; y: number }; - data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData; + data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData | BriefNodeData; stale?: boolean; staleReason?: string; }