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:
parent
3a69234819
commit
5e5fcd7681
|
|
@ -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) ── */
|
||||||
@media (max-width: 768px) {
|
.cp-preview-banner {
|
||||||
.cp-brief-panel.open {
|
display: flex;
|
||||||
position: absolute;
|
align-items: center;
|
||||||
top: 0;
|
justify-content: space-between;
|
||||||
left: 0;
|
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%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 180px;
|
||||||
z-index: 20;
|
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-modal {
|
||||||
|
width: 95%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">🎯</span>';
|
case 'goal': return '<span style="font-size:14px">🎯</span>';
|
||||||
case 'message': return '<span style="font-size:14px">💬</span>';
|
case 'message': return '<span style="font-size:14px">💬</span>';
|
||||||
case 'tone': return '<span style="font-size:14px">🎭</span>';
|
case 'tone': return '<span style="font-size:14px">🎭</span>';
|
||||||
|
case 'brief': return '<span style="font-size:14px">✨</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>
|
||||||
|
|
||||||
|
${this.previewNodeIds.size > 0 ? `
|
||||||
|
<div class="cp-preview-banner">
|
||||||
|
<span class="cp-preview-banner__label">\u2728 AI-generated preview \u2014 ${this.previewNodeIds.size} node${this.previewNodeIds.size === 1 ? '' : 's'}</span>
|
||||||
|
<div class="cp-preview-banner__actions">
|
||||||
|
<button class="cp-btn cp-btn--primary" id="preview-keep">Keep</button>
|
||||||
|
<button class="cp-btn cp-btn--brief" id="preview-regen">Regenerate</button>
|
||||||
|
<button class="cp-btn cp-btn--add" id="preview-discard">Discard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="cp-canvas-area">
|
<div class="cp-canvas-area">
|
||||||
<div class="cp-brief-panel ${this.briefPanelOpen ? 'open' : ''}" id="brief-panel">
|
|
||||||
<div class="cp-brief-header">
|
|
||||||
<span class="cp-brief-title">\u2728 Generate from Brief</span>
|
|
||||||
<button class="cp-brief-close" id="close-brief">\u2715</button>
|
|
||||||
</div>
|
|
||||||
<div class="cp-brief-body">
|
|
||||||
<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 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 & 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">✨</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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
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 getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number {
|
|
||||||
const def = getNodeDef(node.type);
|
|
||||||
if (!def) return node.position.y + NODE_HEIGHT / 2;
|
|
||||||
const ports = direction === 'input' ? def.inputs : def.outputs;
|
|
||||||
const idx = ports.findIndex(p => p.name === portName);
|
|
||||||
if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
|
|
||||||
const spacing = NODE_HEIGHT / (ports.length + 1);
|
|
||||||
return node.position.y + spacing * (idx + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s: string): string {
|
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; });
|
||||||
|
|
|
||||||
|
|
@ -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({
|
return c.html(renderShell({
|
||||||
title: `Campaign Workflows — rSocials | rSpace`,
|
title: `Campaigns — 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({
|
|
||||||
title: `Campaign Workflows — rSocials | rSpace`,
|
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue