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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-16 16:13:20 -04:00
parent 3a69234819
commit 5e5fcd7681
6 changed files with 821 additions and 222 deletions

View File

@ -1050,14 +1050,158 @@ folk-campaign-planner {
filter: drop-shadow(0 0 2px #6366f1); 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) { @media (max-width: 768px) {
.cp-brief-panel.open { .cp-modal {
position: absolute; width: 95%;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
} }
} }

View File

@ -23,6 +23,7 @@ import type {
GoalNodeData, GoalNodeData,
MessageNodeData, MessageNodeData,
ToneNodeData, ToneNodeData,
BriefNodeData,
} from '../schemas'; } from '../schemas';
import { SocialsLocalFirstClient } from '../local-first-client'; import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
@ -68,6 +69,9 @@ const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
tone: [ tone: [
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] }, { 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 ── // ── Edge type → visual config ──
@ -101,6 +105,7 @@ function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } {
case 'goal': return { w: 240, h: 130 }; case 'goal': return { w: 240, h: 130 };
case 'message': return { w: 220, h: 100 }; case 'message': return { w: 220, h: 100 };
case 'tone': return { w: 220, h: 110 }; case 'tone': return { w: 220, h: 110 };
case 'brief': return { w: 260, h: 150 };
default: return { w: 200, h: 100 }; default: return { w: 200, h: 100 };
} }
} }
@ -198,11 +203,21 @@ class FolkCampaignPlanner extends HTMLElement {
// Postiz // Postiz
private postizOpen = false; private postizOpen = false;
// Brief panel // Brief node generation state (keyed by node id)
private briefPanelOpen = false; private briefLoading = new Set<string>();
private briefText = '';
private briefPlatforms: string[] = ['x', 'linkedin', 'instagram', 'threads', 'bluesky']; // Preview state for brief-generated nodes (not yet persisted)
private briefLoading = false; private previewNodeIds = new Set<string>();
private previewEdgeIds = new Set<string>();
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 // Persistence
private localFirstClient: SocialsLocalFirstClient | null = null; private localFirstClient: SocialsLocalFirstClient | null = null;
@ -230,6 +245,7 @@ class FolkCampaignPlanner extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute('space') || 'demo'; this.space = this.getAttribute('space') || 'demo';
this.requestedFlowId = this.getAttribute('flow-id') || '';
this.initData(); this.initData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' })); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' }));
} }
@ -256,8 +272,17 @@ class FolkCampaignPlanner extends HTMLElement {
if (!this.currentFlowId || this.saveTimer) return; if (!this.currentFlowId || this.saveTimer) return;
const flow = doc.campaignFlows?.[this.currentFlowId]; const flow = doc.campaignFlows?.[this.currentFlowId];
if (flow) { if (flow) {
this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })); // Preserve uncommitted preview nodes/edges when applying remote updates
this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); 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') { if (this.currentView === 'canvas') {
this.drawCanvasContent(); this.drawCanvasContent();
} else { } else {
@ -269,7 +294,9 @@ class FolkCampaignPlanner extends HTMLElement {
const activeId = this.localFirstClient.getActiveFlowId(); const activeId = this.localFirstClient.getActiveFlowId();
const flows = this.localFirstClient.listCampaignFlows(); 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); this.loadFlow(activeId);
} else if (flows.length > 0) { } else if (flows.length > 0) {
this.loadFlow(flows[0].id); this.loadFlow(flows[0].id);
@ -315,7 +342,14 @@ class FolkCampaignPlanner extends HTMLElement {
private executeSave() { private executeSave() {
if (this.localFirstClient && this.currentFlowId) { 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) { } else if (this.currentFlowId) {
localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({ localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({
id: this.currentFlowId, name: this.flowName, id: this.currentFlowId, name: this.flowName,
@ -662,6 +696,9 @@ class FolkCampaignPlanner extends HTMLElement {
case 'tone': case 'tone':
data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' }; data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' };
break; break;
case 'brief':
data = { label: 'Campaign Brief', text: '', platforms: ['x', 'linkedin', 'instagram', 'threads', 'bluesky'] };
break;
} }
const node: CampaignPlannerNode = { id, type, position: { x, y }, data }; const node: CampaignPlannerNode = { id, type, position: { x, y }, data };
this.nodes.push(node); this.nodes.push(node);
@ -709,7 +746,7 @@ class FolkCampaignPlanner extends HTMLElement {
overlay.classList.add('inline-edit-overlay'); overlay.classList.add('inline-edit-overlay');
const panelW = 260; 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 panelX = s.w + 12;
const panelY = 0; const panelY = 0;
@ -893,6 +930,28 @@ class FolkCampaignPlanner extends HTMLElement {
</div>`; </div>`;
break; break;
} }
case 'brief': {
const d = node.data as BriefNodeData;
const loading = this.briefLoading.has(node.id);
const plats = d.platforms || [];
body = `
<div class="cp-icp-body">
<label>Campaign Brief</label>
<textarea data-field="text" rows="6" placeholder="Describe your campaign: goal, audience, timeline, key messages...">${esc(d.text)}</textarea>
<label>Platforms</label>
<div class="cp-platform-checkboxes">
${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p =>
`<label class="cp-platform-check">
<input type="checkbox" data-brief-platform="${p}" ${plats.includes(p) ? 'checked' : ''}/>
${p.charAt(0).toUpperCase() + p.slice(1)}
</label>`
).join('')}
</div>
<button class="cp-btn--generate" data-action="generate-brief" ${loading || !d.text.trim() ? 'disabled' : ''}>${loading ? 'Generating...' : '\u2728 Generate Campaign'}</button>
${d.lastGeneratedAt ? `<div style="font-size:10px;color:var(--rs-text-muted);margin-top:6px">Last generated ${new Date(d.lastGeneratedAt).toLocaleString()}</div>` : ''}
</div>`;
break;
}
} }
// AI fill button for post/thread nodes // AI fill button for post/thread nodes
@ -952,8 +1011,15 @@ class FolkCampaignPlanner extends HTMLElement {
if (field === 'text' && node.type === 'message') { if (field === 'text' && node.type === 'message') {
(node.data as MessageNodeData).label = val.substring(0, 40); (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 // 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.markDownstreamStale(node.id);
} }
this.scheduleSave(); this.scheduleSave();
@ -962,6 +1028,19 @@ class FolkCampaignPlanner extends HTMLElement {
el.addEventListener('change', handler); 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 // Actions
panel.querySelectorAll('[data-action]').forEach(el => { panel.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
@ -979,6 +1058,8 @@ class FolkCampaignPlanner extends HTMLElement {
} }
} else if (action === 'ai-fill') { } else if (action === 'ai-fill') {
this.aiFillNode(node.id); this.aiFillNode(node.id);
} else if (action === 'generate-brief') {
this.generateFromBriefNode(node.id);
} }
}); });
}); });
@ -994,6 +1075,7 @@ class FolkCampaignPlanner extends HTMLElement {
case 'goal': return '<span style="font-size:14px">&#x1f3af;</span>'; case 'goal': return '<span style="font-size:14px">&#x1f3af;</span>';
case 'message': return '<span style="font-size:14px">&#x1f4ac;</span>'; case 'message': return '<span style="font-size:14px">&#x1f4ac;</span>';
case 'tone': return '<span style="font-size:14px">&#x1f3ad;</span>'; case 'tone': return '<span style="font-size:14px">&#x1f3ad;</span>';
case 'brief': return '<span style="font-size:14px">&#x2728;</span>';
default: return ''; default: return '';
} }
} }
@ -1397,7 +1479,8 @@ class FolkCampaignPlanner extends HTMLElement {
</button> </button>
</div> </div>
<div class="cp-toolbar__actions"> <div class="cp-toolbar__actions">
<button class="cp-btn cp-btn--brief ${this.briefPanelOpen ? 'active' : ''}" id="toggle-brief">From Brief</button> <button class="cp-btn cp-btn--brief" id="add-brief">\u2728 + Brief</button>
<button class="cp-btn cp-btn--add" id="open-import">\u{1f4e5} Import</button>
<button class="cp-btn cp-btn--regen" id="regen-stale-btn" style="display:${this.getStaleCount() > 0 ? '' : 'none'}">\u26a0 Regen ${this.getStaleCount()} Stale</button> <button class="cp-btn cp-btn--regen" id="regen-stale-btn" style="display:${this.getStaleCount() > 0 ? '' : 'none'}">\u26a0 Regen ${this.getStaleCount()} Stale</button>
<button class="cp-btn cp-btn--add" id="add-post">+ Post</button> <button class="cp-btn cp-btn--add" id="add-post">+ Post</button>
<button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button> <button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button>
@ -1406,27 +1489,18 @@ class FolkCampaignPlanner extends HTMLElement {
</div> </div>
</div> </div>
<div class="cp-canvas-area"> ${this.previewNodeIds.size > 0 ? `
<div class="cp-brief-panel ${this.briefPanelOpen ? 'open' : ''}" id="brief-panel"> <div class="cp-preview-banner">
<div class="cp-brief-header"> <span class="cp-preview-banner__label">\u2728 AI-generated preview \u2014 ${this.previewNodeIds.size} node${this.previewNodeIds.size === 1 ? '' : 's'}</span>
<span class="cp-brief-title">\u2728 Generate from Brief</span> <div class="cp-preview-banner__actions">
<button class="cp-brief-close" id="close-brief">\u2715</button> <button class="cp-btn cp-btn--primary" id="preview-keep">Keep</button>
</div> <button class="cp-btn cp-btn--brief" id="preview-regen">Regenerate</button>
<div class="cp-brief-body"> <button class="cp-btn cp-btn--add" id="preview-discard">Discard</button>
<label>Campaign Brief</label>
<textarea id="brief-text" placeholder="Describe your campaign: goal, audience, timeline, key messages...">${esc(this.briefText)}</textarea>
<label>Platforms</label>
<div class="cp-platform-checkboxes">
${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p =>
`<label class="cp-platform-check">
<input type="checkbox" value="${p}" ${this.briefPlatforms.includes(p) ? 'checked' : ''}/>
${p.charAt(0).toUpperCase() + p.slice(1)}
</label>`
).join('')}
</div>
<button class="cp-btn--generate" id="brief-generate" ${this.briefLoading ? 'disabled' : ''}>${this.briefLoading ? 'Generating...' : 'Generate Graph'}</button>
</div> </div>
</div> </div>
` : ''}
<div class="cp-canvas-area">
<div class="cp-canvas" id="cp-canvas"> <div class="cp-canvas" id="cp-canvas">
<svg id="cp-svg" width="100%" height="100%"> <svg id="cp-svg" width="100%" height="100%">
<defs> <defs>
@ -1457,6 +1531,26 @@ class FolkCampaignPlanner extends HTMLElement {
<iframe class="cp-postiz-iframe" src="${this.postizOpen ? schedulerUrl : 'about:blank'}" title="Postiz"></iframe> <iframe class="cp-postiz-iframe" src="${this.postizOpen ? schedulerUrl : 'about:blank'}" title="Postiz"></iframe>
</div> </div>
</div> </div>
<div class="cp-modal-overlay" id="import-modal" ${this.importModalOpen ? '' : 'hidden'}>
<div class="cp-modal">
<div class="cp-modal__header">
<h3>Import Posts from Markdown</h3>
<button class="cp-modal__close" id="import-close">\u00d7</button>
</div>
<p class="cp-modal__hint">Paste tweets or posts separated by <code>---</code> on its own line. Each becomes a post node wired to the selected platform.</p>
<textarea class="cp-modal__textarea" id="import-text" placeholder="First tweet\n---\nSecond tweet\n---\nThird tweet">${esc(this.importText)}</textarea>
<div class="cp-modal__row">
<label class="cp-modal__label">Platform</label>
<select class="cp-modal__select" id="import-platform">
${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
`<option value="${p}" ${this.importPlatform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
).join('')}
</select>
<button class="cp-btn cp-btn--primary" id="import-submit">Parse &amp; Add</button>
</div>
</div>
</div>
</div> </div>
`; `;
@ -1503,12 +1597,14 @@ class FolkCampaignPlanner extends HTMLElement {
case 'goal': inner = this.renderGoalNodeInner(node); break; case 'goal': inner = this.renderGoalNodeInner(node); break;
case 'message': inner = this.renderMessageNodeInner(node); break; case 'message': inner = this.renderMessageNodeInner(node); break;
case 'tone': inner = this.renderToneNodeInner(node); break; case 'tone': inner = this.renderToneNodeInner(node); break;
case 'brief': inner = this.renderBriefNodeInner(node); break;
} }
const ports = this.renderPortsSvg(node); const ports = this.renderPortsSvg(node);
const previewClass = this.previewNodeIds.has(node.id) ? ' cp-node--preview' : '';
return ` return `
<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}" data-node-id="${node.id}" data-node-type="${node.type}"> <g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}${previewClass}" data-node-id="${node.id}" data-node-type="${node.type}">
<foreignObject x="${x}" y="${y}" width="${s.w}" height="${s.h}"> <foreignObject x="${x}" y="${y}" width="${s.w}" height="${s.h}">
${inner} ${inner}
</foreignObject> </foreignObject>
@ -1629,6 +1725,27 @@ class FolkCampaignPlanner extends HTMLElement {
</div>`; </div>`;
} }
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 => `<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">${p}</span>`).join('');
const extra = plats.length > 4 ? `<span style="font-size:9px;color:var(--rs-text-muted)">+${plats.length - 4}</span>` : '';
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#ec48990a;border:1px solid #ec489944;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
<div style="width:4px;background:#ec4899;flex-shrink:0"></div>
<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:14px">&#x2728;</span>
<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Campaign Brief')}</span>
${loading ? '<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">generating…</span>' : ''}
</div>
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview) || '<em>Click to write a brief</em>'}</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-top:auto">${platPills}${extra}</div>
</div>
</div>`;
}
private renderToneNodeInner(node: CampaignPlannerNode): string { private renderToneNodeInner(node: CampaignPlannerNode): string {
const d = node.data as ToneNodeData; const d = node.data as ToneNodeData;
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#8b5cf60a;border:1px solid #8b5cf633;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px"> return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#8b5cf60a;border:1px solid #8b5cf633;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
@ -1670,20 +1787,29 @@ class FolkCampaignPlanner extends HTMLElement {
return this.nodes.filter(n => n.stale).length; return this.nodes.filter(n => n.stale).length;
} }
// ── Brief panel methods ── // ── Brief-node generation ──
private async generateFromBrief() { private async generateFromBriefNode(briefNodeId: string, options?: { discardPrevious?: boolean }) {
if (this.briefLoading || !this.briefText.trim()) return; const briefNode = this.nodes.find(n => n.id === briefNodeId);
this.briefLoading = true; if (!briefNode || briefNode.type !== 'brief') return;
this.updateBriefPanel(); 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 { try {
const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, { const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
rawBrief: this.briefText, rawBrief: d.text,
platforms: this.briefPlatforms, platforms: d.platforms,
}), }),
}); });
@ -1693,22 +1819,167 @@ class FolkCampaignPlanner extends HTMLElement {
} }
const flow: CampaignFlow = await res.json(); 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(); this.render();
requestAnimationFrame(() => this.fitView()); requestAnimationFrame(() => this.fitView());
} catch (e: any) { } catch (e: any) {
console.error('[CampaignPlanner] Generate from brief error:', e.message); console.error('[CampaignPlanner] Generate from brief error:', e.message);
alert('Failed to generate: ' + e.message); alert('Failed to generate: ' + e.message);
} finally { } 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() { private async regenStaleNodes() {
const staleIds = this.nodes.filter(n => n.stale).map(n => n.id); const staleIds = this.nodes.filter(n => n.stale).map(n => n.id);
if (staleIds.length === 0) return; 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() { private updateToolbarStaleCount() {
const btn = this.shadow.getElementById('regen-stale-btn'); const btn = this.shadow.getElementById('regen-stale-btn');
const count = this.getStaleCount(); const count = this.getStaleCount();
@ -1933,33 +2195,34 @@ class FolkCampaignPlanner extends HTMLElement {
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom; const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
this.addNode('audience', cx, cy); 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 // Import modal
this.shadow.getElementById('toggle-brief')?.addEventListener('click', () => { this.shadow.getElementById('open-import')?.addEventListener('click', () => this.openImportModal());
this.briefPanelOpen = !this.briefPanelOpen; this.shadow.getElementById('import-close')?.addEventListener('click', () => this.closeImportModal());
const panel = this.shadow.getElementById('brief-panel'); this.shadow.getElementById('import-modal')?.addEventListener('click', (e) => {
if (panel) panel.classList.toggle('open', this.briefPanelOpen); if ((e.target as HTMLElement).id === 'import-modal') this.closeImportModal();
}); });
this.shadow.getElementById('close-brief')?.addEventListener('click', () => { this.shadow.getElementById('import-text')?.addEventListener('input', (e) => {
this.briefPanelOpen = false; this.importText = (e.target as HTMLTextAreaElement).value;
const panel = this.shadow.getElementById('brief-panel');
if (panel) panel.classList.remove('open');
}); });
this.shadow.getElementById('brief-text')?.addEventListener('input', (e) => { this.shadow.getElementById('import-platform')?.addEventListener('change', (e) => {
this.briefText = (e.target as HTMLTextAreaElement).value; this.importPlatform = (e.target as HTMLSelectElement).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-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 // Regen stale
this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => { this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => {

View File

@ -604,9 +604,9 @@ export class FolkCampaignWizard extends HTMLElement {
</div>` : result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''} </div>` : result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''} ${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''}
<div class="cw-success__links"> <div class="cw-success__links">
<a href="${this.basePath}/campaign">View Campaign</a> ${result.flowId ? `<a href="${this.basePath}/campaign-flow?id=${result.flowId}">Open in Planner</a>` : ''}
<a href="${this.basePath}/campaigns">All Campaigns</a>
<a href="${this.basePath}/threads">View Threads</a> <a href="${this.basePath}/threads">View Threads</a>
<a href="${this.basePath}/campaigns?workflow=${result.workflowId || ''}">View Workflow</a>
<a href="${this.basePath}/campaign-wizard">New Wizard</a> <a href="${this.basePath}/campaign-wizard">New Wizard</a>
</div> </div>
</div> </div>

View File

@ -1,8 +1,9 @@
/** /**
* <folk-campaigns-dashboard> Campaign workflow gallery grid. * <folk-campaigns-dashboard> Campaign gallery grid.
* *
* Fetches all campaign workflows and renders them as cards with miniature * Lists all campaign flows and renders mini SVG previews. Click a card to
* SVG previews of the node graph. Click a card to open the workflow editor. * open that flow in the planner. Also surfaces the AI wizard (which sets up
* a new flow) and a blank "+ New" affordance.
* *
* Attributes: * Attributes:
* space space slug (default "demo") * space space slug (default "demo")
@ -10,39 +11,43 @@
import { TourEngine } from '../../../shared/tour-engine'; import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine';
import { CAMPAIGN_NODE_CATALOG } from '../schemas';
import type { import type {
CampaignWorkflowNodeDef, CampaignFlow,
CampaignWorkflowNodeCategory, CampaignPlannerNode,
CampaignWorkflowNode, CampaignEdge,
CampaignWorkflowEdge, PhaseNodeData,
CampaignWorkflow,
} from '../schemas'; } from '../schemas';
// ── Constants (match folk-campaign-workflow.ts) ── // ── Mini SVG constants ──
const NODE_WIDTH = 220; const NODE_COLORS: Record<string, string> = {
const NODE_HEIGHT = 80; post: '#3b82f6',
thread: '#8b5cf6',
const CATEGORY_COLORS: Record<CampaignWorkflowNodeCategory, string> = { platform: '#10b981',
trigger: '#3b82f6', audience: '#f59e0b',
delay: '#a855f7', phase: '#64748b',
condition: '#f59e0b', goal: '#6366f1',
action: '#10b981', message: '#6366f1',
tone: '#8b5cf6',
brief: '#ec4899',
}; };
function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined { function nodeSize(n: CampaignPlannerNode): { w: number; h: number } {
return CAMPAIGN_NODE_CATALOG.find(n => n.type === type); switch (n.type) {
} case 'post': return { w: 240, h: 120 };
case 'thread': return { w: 240, h: 100 };
function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number { case 'platform': return { w: 180, h: 80 };
const def = getNodeDef(node.type); case 'audience': return { w: 180, h: 80 };
if (!def) return node.position.y + NODE_HEIGHT / 2; case 'phase': {
const ports = direction === 'input' ? def.inputs : def.outputs; const d = n.data as PhaseNodeData;
const idx = ports.findIndex(p => p.name === portName); return { w: d.size?.w || 400, h: d.size?.h || 300 };
if (idx === -1) return node.position.y + NODE_HEIGHT / 2; }
const spacing = NODE_HEIGHT / (ports.length + 1); case 'goal': return { w: 240, h: 130 };
return node.position.y + spacing * (idx + 1); 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 { function esc(s: string): string {
@ -53,78 +58,81 @@ function esc(s: string): string {
// ── Mini SVG renderer ── // ── Mini SVG renderer ──
function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): string { function renderMiniFlowSVG(nodes: CampaignPlannerNode[], edges: CampaignEdge[]): string {
if (nodes.length === 0) { if (nodes.length === 0) {
return `<svg viewBox="0 0 200 100" preserveAspectRatio="xMidYMid meet" class="cd-card__svg"> return `<svg viewBox="0 0 200 100" preserveAspectRatio="xMidYMid meet" class="cd-card__svg">
<text x="100" y="55" text-anchor="middle" fill="#666" font-size="14">Empty workflow</text> <text x="100" y="55" text-anchor="middle" fill="#666" font-size="14">Empty flow</text>
</svg>`; </svg>`;
} }
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) { for (const n of nodes) {
const s = nodeSize(n);
minX = Math.min(minX, n.position.x); minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y); minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + NODE_WIDTH); maxX = Math.max(maxX, n.position.x + s.w);
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); maxY = Math.max(maxY, n.position.y + s.h);
} }
const pad = 20; const pad = 40;
const vx = minX - pad; const vx = minX - pad;
const vy = minY - pad; const vy = minY - pad;
const vw = maxX - minX + pad * 2; const vw = (maxX - minX) + pad * 2;
const vh = maxY - minY + 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 edgePaths = edges.map(e => {
const fromNode = nodes.find(n => n.id === e.fromNode); const from = nodes.find(n => n.id === e.from);
const toNode = nodes.find(n => n.id === e.toNode); const to = nodes.find(n => n.id === e.to);
if (!fromNode || !toNode) return ''; if (!from || !to) return '';
const x1 = fromNode.position.x + NODE_WIDTH; const fs = nodeSize(from);
const y1 = getPortY(fromNode, e.fromPort, 'output'); const ts = nodeSize(to);
const x2 = toNode.position.x; const x1 = from.position.x + fs.w;
const y2 = getPortY(toNode, e.toPort, 'input'); 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; const dx = Math.abs(x2 - x1) * 0.5;
return `<path d="M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}" fill="none" stroke="#555" stroke-width="2" opacity="0.6"/>`; return `<path d="M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}" fill="none" stroke="#555" stroke-width="4" opacity="0.5"/>`;
}).join(''); }).join('');
// Render nodes as rects // Phase rects behind
const nodeRects = nodes.map(n => { const phaseRects = nodes.filter(n => n.type === 'phase').map(n => {
const def = getNodeDef(n.type); const s = nodeSize(n);
const cat = def?.category || 'action'; const d = n.data as PhaseNodeData;
const color = CATEGORY_COLORS[cat] || '#666'; return `<rect x="${n.position.x}" y="${n.position.y}" width="${s.w}" height="${s.h}" rx="8" fill="${d.color || '#64748b'}" opacity="0.08" stroke="${d.color || '#64748b'}" stroke-opacity="0.25"/>`;
const label = def?.icon ? `${def.icon} ${esc(n.label || def.label)}` : esc(n.label || n.type); }).join('');
// Truncate long labels for mini view
const shortLabel = label.length > 18 ? label.slice(0, 16) + '…' : label; // Content nodes as solid rects
return ` const contentRects = nodes.filter(n => n.type !== 'phase').map(n => {
<rect x="${n.position.x}" y="${n.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="8" fill="${color}" opacity="0.85"/> const s = nodeSize(n);
<text x="${n.position.x + NODE_WIDTH / 2}" y="${n.position.y + NODE_HEIGHT / 2 + 5}" text-anchor="middle" fill="#fff" font-size="13" font-weight="500">${shortLabel}</text> const color = NODE_COLORS[n.type] || '#666';
`; return `<rect x="${n.position.x}" y="${n.position.y}" width="${s.w}" height="${s.h}" rx="10" fill="${color}" opacity="0.85"/>`;
}).join(''); }).join('');
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" class="cd-card__svg"> return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" class="cd-card__svg">
${phaseRects}
${edgePaths} ${edgePaths}
${nodeRects} ${contentRects}
</svg>`; </svg>`;
} }
// ── Component ── // ── Component ──
const DASHBOARD_TOUR_STEPS: TourStep[] = [ 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-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 Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' }, { 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: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' }, { target: '.cd-card', title: 'Campaign Cards', message: 'Click any card to open its flow in the planner.' },
]; ];
class FolkCampaignsDashboard extends HTMLElement { class FolkCampaignsDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ''; private space = '';
private workflows: CampaignWorkflow[] = []; private flows: CampaignFlow[] = [];
private loading = true; private loading = true;
private _tour!: TourEngine; private _tour!: TourEngine;
private get basePath() { private get basePath() {
const host = window.location.hostname; 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/`; return `/${this.space}/rsocials/`;
} }
@ -142,18 +150,18 @@ class FolkCampaignsDashboard extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute('space') || 'demo'; this.space = this.getAttribute('space') || 'demo';
this.render(); this.render();
this.loadWorkflows(); this.loadFlows();
} }
private async loadWorkflows() { private async loadFlows() {
try { try {
const res = await fetch(`${this.basePath}api/campaign-workflows`); const res = await fetch(`${this.basePath}api/campaign/flows`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
this.workflows = data.results || []; this.flows = data.results || [];
} }
} catch { } catch {
console.warn('[CampaignsDashboard] Failed to load workflows'); console.warn('[CampaignsDashboard] Failed to load flows');
} }
this.loading = false; this.loading = false;
this.render(); this.render();
@ -164,26 +172,24 @@ class FolkCampaignsDashboard extends HTMLElement {
startTour() { this._tour.start(); } startTour() { this._tour.start(); }
private async createWorkflow() { private async createFlow() {
try { try {
const res = await fetch(`${this.basePath}api/campaign-workflows`, { const res = await fetch(`${this.basePath}api/campaign/flows`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Campaign Workflow' }), body: JSON.stringify({ name: 'New Campaign' }),
}); });
if (res.ok) { if (res.ok) {
const wf = await res.json(); const flow = await res.json();
this.navigateToWorkflow(wf.id); this.navigateToFlow(flow.id);
} }
} catch { } catch {
console.error('[CampaignsDashboard] Failed to create workflow'); console.error('[CampaignsDashboard] Failed to create flow');
} }
} }
private navigateToWorkflow(id: string) { private navigateToFlow(id: string) {
const url = new URL(window.location.href); window.location.href = `${this.basePath}campaign-flow?id=${encodeURIComponent(id)}`;
url.searchParams.set('workflow', id);
window.location.href = url.toString();
} }
private formatDate(ts: number | null): string { private formatDate(ts: number | null): string {
@ -198,47 +204,43 @@ class FolkCampaignsDashboard extends HTMLElement {
} }
private render() { private render() {
const cards = this.workflows.map(wf => { const cards = this.flows.map(flow => {
const nodeCount = wf.nodes.length; const nodeCount = flow.nodes.length;
const statusClass = wf.enabled ? 'cd-badge--enabled' : 'cd-badge--disabled'; const postCount = flow.nodes.filter(n => n.type === 'post').length;
const statusLabel = wf.enabled ? 'Enabled' : 'Disabled'; const platformCount = flow.nodes.filter(n => n.type === 'platform').length;
const runBadge = wf.lastRunStatus
? `<span class="cd-badge cd-badge--${wf.lastRunStatus === 'success' ? 'success' : 'error'}">${wf.lastRunStatus}</span>`
: '';
return ` return `
<div class="cd-card" data-wf-id="${wf.id}"> <div class="cd-card" data-flow-id="${flow.id}">
<div class="cd-card__preview"> <div class="cd-card__preview">
${renderMiniSVG(wf.nodes, wf.edges)} ${renderMiniFlowSVG(flow.nodes, flow.edges)}
</div> </div>
<div class="cd-card__info"> <div class="cd-card__info">
<div class="cd-card__name">${esc(wf.name)}</div> <div class="cd-card__name">${esc(flow.name)}</div>
<div class="cd-card__badges"> <div class="cd-card__badges">
<span class="cd-badge ${statusClass}">${statusLabel}</span>
<span class="cd-badge cd-badge--nodes">${nodeCount} node${nodeCount !== 1 ? 's' : ''}</span> <span class="cd-badge cd-badge--nodes">${nodeCount} node${nodeCount !== 1 ? 's' : ''}</span>
${runBadge} ${postCount > 0 ? `<span class="cd-badge cd-badge--posts">${postCount} post${postCount !== 1 ? 's' : ''}</span>` : ''}
${wf.runCount > 0 ? `<span class="cd-badge cd-badge--runs">${wf.runCount} run${wf.runCount !== 1 ? 's' : ''}</span>` : ''} ${platformCount > 0 ? `<span class="cd-badge cd-badge--platforms">${platformCount} platform${platformCount !== 1 ? 's' : ''}</span>` : ''}
</div> </div>
<div class="cd-card__date">Updated ${this.formatDate(wf.updatedAt)}</div> <div class="cd-card__date">Updated ${this.formatDate(flow.updatedAt)}</div>
</div> </div>
</div> </div>
`; `;
}).join(''); }).join('');
const emptyState = !this.loading && this.workflows.length === 0 ? ` const emptyState = !this.loading && this.flows.length === 0 ? `
<div class="cd-empty"> <div class="cd-empty">
<div class="cd-empty__icon">📋</div> <div class="cd-empty__icon">📢</div>
<div class="cd-empty__title">No campaign workflows yet</div> <div class="cd-empty__title">No campaigns yet</div>
<div class="cd-empty__subtitle">Use the AI wizard for guided campaign creation, or start a blank workflow</div> <div class="cd-empty__subtitle">Use the AI wizard to set up a flow from a brief, or start a blank canvas</div>
<div class="cd-header__actions" style="justify-content:center"> <div class="cd-header__actions" style="justify-content:center">
<button class="cd-btn cd-btn--wizard cd-btn--new-empty" id="btn-wizard-empty">\uD83E\uDDD9 Campaign Wizard</button> <button class="cd-btn cd-btn--wizard cd-btn--new-empty" id="btn-wizard-empty">\uD83E\uDDD9 Campaign Wizard</button>
<button class="cd-btn cd-btn--primary cd-btn--new-empty">+ New Workflow</button> <button class="cd-btn cd-btn--primary cd-btn--new-empty">+ New Campaign</button>
</div> </div>
</div> </div>
` : ''; ` : '';
const loadingState = this.loading ? ` const loadingState = this.loading ? `
<div class="cd-loading">Loading workflows</div> <div class="cd-loading">Loading campaigns</div>
` : ''; ` : '';
this.shadow.innerHTML = ` 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 { 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 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 { .cd-btn {
border: none; border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; 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-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px;
font-weight: 500; text-transform: capitalize; 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--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); } .cd-card__date { font-size: 0.75rem; color: var(--rs-text-muted, #888); }
@ -312,16 +312,19 @@ class FolkCampaignsDashboard extends HTMLElement {
<div class="cd-root"> <div class="cd-root">
<div class="cd-header"> <div class="cd-header">
<h2>Campaign Workflows</h2> <div>
<h2>Campaigns</h2>
<div class="cd-header__subtitle">Plan, wire up, and schedule multi-platform campaigns</div>
</div>
<div class="cd-header__actions"> <div class="cd-header__actions">
<button class="cd-btn cd-btn--wizard" id="btn-wizard">\uD83E\uDDD9 Campaign Wizard</button> <button class="cd-btn cd-btn--wizard" id="btn-wizard">\uD83E\uDDD9 Campaign Wizard</button>
${!this.loading && this.workflows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Workflow</button>' : ''} ${!this.loading && this.flows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Campaign</button>' : ''}
<button style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;" id="btn-tour">Tour</button> <button style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;" id="btn-tour">Tour</button>
</div> </div>
</div> </div>
${loadingState} ${loadingState}
${emptyState} ${emptyState}
${!this.loading && this.workflows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''} ${!this.loading && this.flows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''}
</div> </div>
`; `;
@ -330,22 +333,19 @@ class FolkCampaignsDashboard extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
// Card clicks
this.shadow.querySelectorAll('.cd-card').forEach(card => { this.shadow.querySelectorAll('.cd-card').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const id = (card as HTMLElement).dataset.wfId; const id = (card as HTMLElement).dataset.flowId;
if (id) this.navigateToWorkflow(id); if (id) this.navigateToFlow(id);
}); });
}); });
// New workflow buttons
const btnNew = this.shadow.getElementById('btn-new'); 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)'); 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 wizardUrl = `${this.basePath}campaign-wizard`;
const btnWizard = this.shadow.getElementById('btn-wizard'); const btnWizard = this.shadow.getElementById('btn-wizard');
if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; }); if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; });

View File

@ -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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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 ── // ── Campaign Workflow CRUD API ──
routes.get("/api/campaign-workflows", (c) => { 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; (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<string, string>();
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<SocialsDoc>(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<SocialsDoc>(docId, `wizard ${id} → committed`, (d) => { _syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → committed`, (d) => {
const w = d.campaignWizards?.[id]; const w = d.campaignWizards?.[id];
if (!w) return; if (!w) return;
@ -1887,6 +2083,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
return c.json({ return c.json({
ok: true, ok: true,
campaignId: campaign.id, campaignId: campaign.id,
flowId,
threadIds, threadIds,
threads: threadInfos, threads: threadInfos,
workflowId: wfId, workflowId: wfId,
@ -2308,13 +2505,15 @@ Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goa
routes.get("/campaign-flow", (c) => { routes.get("/campaign-flow", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const flowId = c.req.query("id") || "";
const idAttr = flowId ? ` flow-id="${escapeHtml(flowId)}"` : "";
return c.html(renderShell({ return c.html(renderShell({
title: `Campaign Flow — rSocials | rSpace`, title: `Campaign Flow — rSocials | rSpace`,
moduleId: "rsocials", moduleId: "rsocials",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-campaign-planner space="${escapeHtml(space)}"></folk-campaign-planner>`, body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`, styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`, scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`,
})); }));
@ -2462,23 +2661,8 @@ routes.get("/threads", (c) => {
routes.get("/campaigns", (c) => { routes.get("/campaigns", (c) => {
const space = c.req.param("space") || "demo"; 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: `<folk-campaign-workflow space="${escapeHtml(space)}" workflow="${escapeHtml(workflowId)}"></folk-campaign-workflow>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-workflow.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-workflow.js"></script>`,
}));
}
return c.html(renderShell({ return c.html(renderShell({
title: `Campaign Workflows — rSocials | rSpace`, title: `Campaigns — rSocials | rSpace`,
moduleId: "rsocials", moduleId: "rsocials",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),

View File

@ -56,7 +56,7 @@ export interface Campaign {
// ── Campaign planner (flow canvas) types ── // ── 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 { export interface PostNodeData {
label: string; label: string;
@ -118,11 +118,19 @@ export interface ToneNodeData {
sizeEstimate: string; sizeEstimate: string;
} }
export interface BriefNodeData {
label: string;
text: string;
platforms: string[];
generating?: boolean;
lastGeneratedAt?: number;
}
export interface CampaignPlannerNode { export interface CampaignPlannerNode {
id: string; id: string;
type: CampaignNodeType; type: CampaignNodeType;
position: { x: number; y: number }; 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; stale?: boolean;
staleReason?: string; staleReason?: string;
} }