Compare commits
2 Commits
c3f8e9ef1b
...
ae1ab57a99
| Author | SHA1 | Date |
|---|---|---|
|
|
ae1ab57a99 | |
|
|
d49b4ea2dc |
|
|
@ -859,3 +859,205 @@ folk-campaign-planner {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Brief panel (collapsible left sidebar) ── */
|
||||||
|
.cp-brief-panel {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--rs-border, #2d2d44);
|
||||||
|
background: var(--rs-bg-surface, #1a1a2e);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-panel.open {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--rs-border, #2d2d44);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--rs-text-muted, #94a3b8);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-close:hover {
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-body label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--rs-text-muted, #94a3b8);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-brief-body textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--rs-input-border, #3d3d5c);
|
||||||
|
background: var(--rs-input-bg, #16162a);
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Platform selector chips ── */
|
||||||
|
.cp-platform-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-platform-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--rs-input-border, #3d3d5c);
|
||||||
|
background: var(--rs-input-bg, #16162a);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-platform-check:has(input:checked) {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #6366f122;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-platform-check input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Brief / Generate buttons ── */
|
||||||
|
.cp-btn--brief {
|
||||||
|
background: #8b5cf622;
|
||||||
|
border-color: #8b5cf655;
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--brief:hover {
|
||||||
|
background: #8b5cf633;
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--brief.active {
|
||||||
|
background: #8b5cf633;
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--generate {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--generate:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--generate:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stale badge ── */
|
||||||
|
.cp-stale-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f59e0b22;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Regen button (amber) ── */
|
||||||
|
.cp-btn--regen {
|
||||||
|
background: #f59e0b22;
|
||||||
|
border-color: #f59e0b55;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn--regen:hover {
|
||||||
|
background: #f59e0b33;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AI fill button (indigo) ── */
|
||||||
|
.cp-icp-btn-ai {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-icp-btn-ai:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-icp-btn-ai:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feeds edge style ── */
|
||||||
|
.edge-path-feeds {
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
filter: drop-shadow(0 0 2px #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile: brief panel ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cp-brief-panel.open {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import type {
|
||||||
PlatformNodeData,
|
PlatformNodeData,
|
||||||
AudienceNodeData,
|
AudienceNodeData,
|
||||||
PhaseNodeData,
|
PhaseNodeData,
|
||||||
|
GoalNodeData,
|
||||||
|
MessageNodeData,
|
||||||
|
ToneNodeData,
|
||||||
} 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';
|
||||||
|
|
@ -41,11 +44,13 @@ const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
|
||||||
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
|
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
|
||||||
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
|
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
|
||||||
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
|
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
|
||||||
|
{ kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' },
|
||||||
],
|
],
|
||||||
thread: [
|
thread: [
|
||||||
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
|
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
|
||||||
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
|
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
|
||||||
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
|
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
|
||||||
|
{ kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' },
|
||||||
],
|
],
|
||||||
platform: [
|
platform: [
|
||||||
{ kind: 'content-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#10b981' },
|
{ kind: 'content-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#10b981' },
|
||||||
|
|
@ -54,6 +59,15 @@ const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
|
||||||
{ kind: 'target-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#f59e0b' },
|
{ kind: 'target-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#f59e0b' },
|
||||||
],
|
],
|
||||||
phase: [],
|
phase: [],
|
||||||
|
goal: [
|
||||||
|
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
|
||||||
|
],
|
||||||
|
message: [
|
||||||
|
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
|
||||||
|
],
|
||||||
|
tone: [
|
||||||
|
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Edge type → visual config ──
|
// ── Edge type → visual config ──
|
||||||
|
|
@ -62,12 +76,14 @@ const EDGE_STYLES: Record<CampaignEdgeType, { width: number; dash: string; anima
|
||||||
publish: { width: 3, dash: '8 4', animated: true },
|
publish: { width: 3, dash: '8 4', animated: true },
|
||||||
sequence: { width: 2, dash: '', animated: false },
|
sequence: { width: 2, dash: '', animated: false },
|
||||||
target: { width: 2, dash: '4 6', animated: false },
|
target: { width: 2, dash: '4 6', animated: false },
|
||||||
|
feeds: { width: 2, dash: '6 4', animated: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const EDGE_COLORS: Record<CampaignEdgeType, string> = {
|
const EDGE_COLORS: Record<CampaignEdgeType, string> = {
|
||||||
publish: '#10b981',
|
publish: '#10b981',
|
||||||
sequence: '#8b5cf6',
|
sequence: '#8b5cf6',
|
||||||
target: '#f59e0b',
|
target: '#f59e0b',
|
||||||
|
feeds: '#6366f1',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Node sizes ──
|
// ── Node sizes ──
|
||||||
|
|
@ -82,6 +98,9 @@ function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } {
|
||||||
const d = node.data as PhaseNodeData;
|
const d = node.data as PhaseNodeData;
|
||||||
return { w: d.size.w, h: d.size.h };
|
return { w: d.size.w, h: d.size.h };
|
||||||
}
|
}
|
||||||
|
case 'goal': return { w: 240, h: 130 };
|
||||||
|
case 'message': return { w: 220, h: 100 };
|
||||||
|
case 'tone': return { w: 220, h: 110 };
|
||||||
default: return { w: 200, h: 100 };
|
default: return { w: 200, h: 100 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +198,12 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
// Postiz
|
// Postiz
|
||||||
private postizOpen = false;
|
private postizOpen = false;
|
||||||
|
|
||||||
|
// Brief panel
|
||||||
|
private briefPanelOpen = false;
|
||||||
|
private briefText = '';
|
||||||
|
private briefPlatforms: string[] = ['x', 'linkedin', 'instagram', 'threads', 'bluesky'];
|
||||||
|
private briefLoading = false;
|
||||||
|
|
||||||
// Persistence
|
// Persistence
|
||||||
private localFirstClient: SocialsLocalFirstClient | null = null;
|
private localFirstClient: SocialsLocalFirstClient | null = null;
|
||||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
@ -535,7 +560,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
|
|
||||||
// Determine edge type from port kinds
|
// Determine edge type from port kinds
|
||||||
let edgeType: CampaignEdgeType = 'publish';
|
let edgeType: CampaignEdgeType = 'publish';
|
||||||
if (this.wiringSourcePortKind === 'sequence-out') {
|
if (this.wiringSourcePortKind === 'feeds-out') {
|
||||||
|
edgeType = 'feeds';
|
||||||
|
} else if (this.wiringSourcePortKind === 'sequence-out') {
|
||||||
edgeType = 'sequence';
|
edgeType = 'sequence';
|
||||||
} else {
|
} else {
|
||||||
// Check what kind of input the target has
|
// Check what kind of input the target has
|
||||||
|
|
@ -626,6 +653,15 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
case 'phase':
|
case 'phase':
|
||||||
data = { label: 'New Phase', dateRange: '', color: '#6366f1', progress: 0, childNodeIds: [], size: { w: 400, h: 300 } };
|
data = { label: 'New Phase', dateRange: '', color: '#6366f1', progress: 0, childNodeIds: [], size: { w: 400, h: 300 } };
|
||||||
break;
|
break;
|
||||||
|
case 'goal':
|
||||||
|
data = { label: 'Campaign Goal', objective: '', startDate: '', endDate: '' };
|
||||||
|
break;
|
||||||
|
case 'message':
|
||||||
|
data = { label: 'Key Message', text: '', priority: 'medium' };
|
||||||
|
break;
|
||||||
|
case 'tone':
|
||||||
|
data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' };
|
||||||
|
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);
|
||||||
|
|
@ -673,7 +709,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' ? 340 : node.type === 'thread' ? 200 : 220;
|
const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : 220;
|
||||||
const panelX = s.w + 12;
|
const panelX = s.w + 12;
|
||||||
const panelY = 0;
|
const panelY = 0;
|
||||||
|
|
||||||
|
|
@ -804,10 +840,68 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'goal': {
|
||||||
|
const d = node.data as GoalNodeData;
|
||||||
|
body = `
|
||||||
|
<div class="cp-icp-body">
|
||||||
|
<label>Title</label>
|
||||||
|
<input data-field="label" value="${esc(d.label)}"/>
|
||||||
|
<label>Objective</label>
|
||||||
|
<textarea data-field="objective" rows="3">${esc(d.objective)}</textarea>
|
||||||
|
<label>Start Date</label>
|
||||||
|
<input type="date" data-field="startDate" value="${d.startDate}"/>
|
||||||
|
<label>End Date</label>
|
||||||
|
<input type="date" data-field="endDate" value="${d.endDate}"/>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'message': {
|
||||||
|
const d = node.data as MessageNodeData;
|
||||||
|
body = `
|
||||||
|
<div class="cp-icp-body">
|
||||||
|
<label>Message</label>
|
||||||
|
<textarea data-field="text" rows="3">${esc(d.text)}</textarea>
|
||||||
|
<label>Priority</label>
|
||||||
|
<select data-field="priority">
|
||||||
|
<option value="high" ${d.priority === 'high' ? 'selected' : ''}>High</option>
|
||||||
|
<option value="medium" ${d.priority === 'medium' ? 'selected' : ''}>Medium</option>
|
||||||
|
<option value="low" ${d.priority === 'low' ? 'selected' : ''}>Low</option>
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tone': {
|
||||||
|
const d = node.data as ToneNodeData;
|
||||||
|
body = `
|
||||||
|
<div class="cp-icp-body">
|
||||||
|
<label>Tone</label>
|
||||||
|
<select data-field="tone">
|
||||||
|
${['professional', 'casual', 'urgent', 'inspirational'].map(t =>
|
||||||
|
`<option value="${t}" ${d.tone === t ? 'selected' : ''}>${t.charAt(0).toUpperCase() + t.slice(1)}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
<label>Style</label>
|
||||||
|
<select data-field="style">
|
||||||
|
${['event-promo', 'product-launch', 'awareness', 'community', 'educational'].map(s =>
|
||||||
|
`<option value="${s}" ${d.style === s ? 'selected' : ''}>${s.replace('-', ' ')}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
<label>Audience</label>
|
||||||
|
<textarea data-field="audience" rows="2">${esc(d.audience)}</textarea>
|
||||||
|
<label>Audience Size</label>
|
||||||
|
<input data-field="sizeEstimate" value="${esc(d.sizeEstimate)}" placeholder="e.g. ~50K"/>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI fill button for post/thread nodes
|
||||||
|
const aiFillBtn = (node.type === 'post' || node.type === 'thread')
|
||||||
|
? `<button class="cp-icp-btn-ai" data-action="ai-fill">✨ AI Fill</button>` : '';
|
||||||
|
|
||||||
const toolbar = `
|
const toolbar = `
|
||||||
<div class="cp-icp-toolbar">
|
<div class="cp-icp-toolbar">
|
||||||
|
${aiFillBtn}
|
||||||
<button class="cp-icp-btn-done" data-action="done">Done</button>
|
<button class="cp-icp-btn-done" data-action="done">Done</button>
|
||||||
<button class="cp-icp-btn-delete" data-action="delete">Delete</button>
|
<button class="cp-icp-btn-delete" data-action="delete">Delete</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -855,6 +949,13 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
if (field === 'content' && node.type === 'post') {
|
if (field === 'content' && node.type === 'post') {
|
||||||
(node.data as PostNodeData).label = val.split('\n')[0].substring(0, 40);
|
(node.data as PostNodeData).label = val.split('\n')[0].substring(0, 40);
|
||||||
}
|
}
|
||||||
|
if (field === 'text' && node.type === 'message') {
|
||||||
|
(node.data as MessageNodeData).label = val.substring(0, 40);
|
||||||
|
}
|
||||||
|
// Mark downstream nodes stale when input nodes change
|
||||||
|
if (node.type === 'goal' || node.type === 'message' || node.type === 'tone') {
|
||||||
|
this.markDownstreamStale(node.id);
|
||||||
|
}
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
};
|
};
|
||||||
el.addEventListener('input', handler);
|
el.addEventListener('input', handler);
|
||||||
|
|
@ -876,6 +977,8 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
if (d.threadId) {
|
if (d.threadId) {
|
||||||
window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
|
window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
|
||||||
}
|
}
|
||||||
|
} else if (action === 'ai-fill') {
|
||||||
|
this.aiFillNode(node.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -888,6 +991,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
case 'platform': return '<span style="font-size:14px">📡</span>';
|
case 'platform': return '<span style="font-size:14px">📡</span>';
|
||||||
case 'audience': return '<span style="font-size:14px">🎯</span>';
|
case 'audience': return '<span style="font-size:14px">🎯</span>';
|
||||||
case 'phase': return '<span style="font-size:14px">📅</span>';
|
case 'phase': 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 'tone': return '<span style="font-size:14px">🎭</span>';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -916,6 +1022,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
{ icon: '\u{1f4e1}', label: 'Add Platform', type: 'platform' },
|
{ icon: '\u{1f4e1}', label: 'Add Platform', type: 'platform' },
|
||||||
{ icon: '\u{1f3af}', label: 'Add Audience', type: 'audience' },
|
{ icon: '\u{1f3af}', label: 'Add Audience', type: 'audience' },
|
||||||
{ icon: '\u{1f4c5}', label: 'Add Phase', type: 'phase' },
|
{ icon: '\u{1f4c5}', label: 'Add Phase', type: 'phase' },
|
||||||
|
{ icon: '\u{1f3af}', label: 'Add Goal', type: 'goal' },
|
||||||
|
{ icon: '\u{1f4ac}', label: 'Add Message', type: 'message' },
|
||||||
|
{ icon: '\u{1f3ad}', label: 'Add Tone', type: 'tone' },
|
||||||
];
|
];
|
||||||
|
|
||||||
menu.innerHTML = items.map(it =>
|
menu.innerHTML = items.map(it =>
|
||||||
|
|
@ -1288,6 +1397,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--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>
|
||||||
<button class="cp-btn cp-btn--add" id="add-audience">+ Audience</button>
|
<button class="cp-btn cp-btn--add" id="add-audience">+ Audience</button>
|
||||||
|
|
@ -1296,6 +1407,26 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
</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>
|
||||||
|
|
@ -1369,6 +1500,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
case 'thread': inner = this.renderThreadNodeInner(node); break;
|
case 'thread': inner = this.renderThreadNodeInner(node); break;
|
||||||
case 'platform': inner = this.renderPlatformNodeInner(node); break;
|
case 'platform': inner = this.renderPlatformNodeInner(node); break;
|
||||||
case 'audience': inner = this.renderAudienceNodeInner(node); break;
|
case 'audience': inner = this.renderAudienceNodeInner(node); break;
|
||||||
|
case 'goal': inner = this.renderGoalNodeInner(node); break;
|
||||||
|
case 'message': inner = this.renderMessageNodeInner(node); break;
|
||||||
|
case 'tone': inner = this.renderToneNodeInner(node); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ports = this.renderPortsSvg(node);
|
const ports = this.renderPortsSvg(node);
|
||||||
|
|
@ -1393,13 +1527,15 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
const charMax = platform === 'x' ? 280 : 2200;
|
const charMax = platform === 'x' ? 280 : 2200;
|
||||||
const charPct = Math.min(1, charCount / charMax) * 100;
|
const charPct = Math.min(1, charCount / charMax) * 100;
|
||||||
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled';
|
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled';
|
||||||
|
const staleBadge = node.stale ? `<span style="display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;background:#f59e0b22;color:#f59e0b;font-size:8px;font-weight:600;flex-shrink:0">\u26a0 stale</span>` : '';
|
||||||
|
|
||||||
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
|
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:var(--rs-bg-surface);border:1px solid ${node.stale ? '#f59e0b55' : 'var(--rs-border)'};border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
|
||||||
<div style="width:4px;background:${color};flex-shrink:0"></div>
|
<div style="width:4px;background:${color};flex-shrink:0"></div>
|
||||||
<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
|
<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">
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
<span style="font-size:12px;width:20px;height:20px;border-radius:4px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0">${icon}</span>
|
<span style="font-size:12px;width:20px;height:20px;border-radius:4px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0">${icon}</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 || preview || 'New Post')}</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 || preview || 'New Post')}</span>
|
||||||
|
${staleBadge}
|
||||||
<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
|
<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview)}</div>
|
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview)}</div>
|
||||||
|
|
@ -1464,6 +1600,197 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderGoalNodeInner(node: CampaignPlannerNode): string {
|
||||||
|
const d = node.data as GoalNodeData;
|
||||||
|
const staleBadge = node.stale ? '' : ''; // goals don't show stale
|
||||||
|
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#6366f10a;border:1px solid #6366f133;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<span style="font-size:16px">🎯</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 Goal')}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.objective || '').substring(0, 60)}</div>
|
||||||
|
<div style="font-size:9px;color:var(--rs-text-muted);margin-top:auto">${d.startDate ? esc(d.startDate) + ' \u2192 ' + esc(d.endDate) : 'No dates set'}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMessageNodeInner(node: CampaignPlannerNode): string {
|
||||||
|
const d = node.data as MessageNodeData;
|
||||||
|
const priorityColor = d.priority === 'high' ? '#ef4444' : d.priority === 'medium' ? '#f59e0b' : '#94a3b8';
|
||||||
|
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#6366f10a;border:1px solid #6366f133;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
|
||||||
|
<div style="width:4px;background:${priorityColor};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 || 'Key Message')}</span>
|
||||||
|
<span style="font-size:9px;padding:2px 6px;border-radius:4px;background:${priorityColor}22;color:${priorityColor};font-weight:600">${d.priority}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.text || '').substring(0, 60)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderToneNodeInner(node: CampaignPlannerNode): string {
|
||||||
|
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">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<span style="font-size:16px">🎭</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 || 'Tone')}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--rs-text-secondary)">${esc(d.tone)} \u00b7 ${esc(d.style.replace('-', ' '))}</div>
|
||||||
|
<div style="font-size:9px;color:var(--rs-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.audience || '').substring(0, 50)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStaleBadge(node: CampaignPlannerNode): string {
|
||||||
|
if (!node.stale) return '';
|
||||||
|
return `<span class="cp-stale-badge">\u26a0 stale</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stale tracking ──
|
||||||
|
|
||||||
|
private markDownstreamStale(nodeId: string) {
|
||||||
|
// Follow outgoing edges from this node and mark downstream post/thread nodes stale
|
||||||
|
const outEdges = this.edges.filter(e => e.from === nodeId);
|
||||||
|
for (const edge of outEdges) {
|
||||||
|
const target = this.nodes.find(n => n.id === edge.to);
|
||||||
|
if (!target) continue;
|
||||||
|
if (target.type === 'post' || target.type === 'thread') {
|
||||||
|
target.stale = true;
|
||||||
|
target.staleReason = `Input node changed`;
|
||||||
|
}
|
||||||
|
// Recurse for phase→post chains
|
||||||
|
if (target.type === 'phase') {
|
||||||
|
this.markDownstreamStale(target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.drawCanvasContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStaleCount(): number {
|
||||||
|
return this.nodes.filter(n => n.stale).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Brief panel methods ──
|
||||||
|
|
||||||
|
private async generateFromBrief() {
|
||||||
|
if (this.briefLoading || !this.briefText.trim()) return;
|
||||||
|
this.briefLoading = true;
|
||||||
|
this.updateBriefPanel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
rawBrief: this.briefText,
|
||||||
|
platforms: this.briefPlatforms,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any).error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.fitView());
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[CampaignPlanner] Generate from brief error:', e.message);
|
||||||
|
alert('Failed to generate: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
this.briefLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async regenStaleNodes() {
|
||||||
|
const staleIds = this.nodes.filter(n => n.stale).map(n => n.id);
|
||||||
|
if (staleIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.basePath}api/campaign/flow/regen-nodes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
flowId: this.currentFlowId,
|
||||||
|
nodeIds: staleIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any).error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.flow) {
|
||||||
|
this.nodes = data.flow.nodes.map((n: any) => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
|
||||||
|
this.edges = data.flow.edges.map((e: any) => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
|
||||||
|
this.drawCanvasContent();
|
||||||
|
this.updateToolbarStaleCount();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[CampaignPlanner] Regen stale error:', e.message);
|
||||||
|
alert('Failed to regenerate: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async aiFillNode(nodeId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.basePath}api/campaign/flow/ai-fill-node`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
flowId: this.currentFlowId,
|
||||||
|
nodeId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any).error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.node) {
|
||||||
|
const idx = this.nodes.findIndex(n => n.id === nodeId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.nodes[idx] = { ...data.node, position: { ...data.node.position }, data: { ...data.node.data } };
|
||||||
|
this.exitInlineEdit();
|
||||||
|
this.drawCanvasContent();
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[CampaignPlanner] AI fill error:', e.message);
|
||||||
|
alert('Failed to AI fill: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBriefPanel() {
|
||||||
|
const panel = this.shadow.getElementById('brief-panel');
|
||||||
|
const genBtn = panel?.querySelector('#brief-generate') as HTMLButtonElement | null;
|
||||||
|
if (genBtn) {
|
||||||
|
genBtn.disabled = this.briefLoading;
|
||||||
|
genBtn.textContent = this.briefLoading ? 'Generating...' : 'Generate Graph';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateToolbarStaleCount() {
|
||||||
|
const btn = this.shadow.getElementById('regen-stale-btn');
|
||||||
|
const count = this.getStaleCount();
|
||||||
|
if (btn) {
|
||||||
|
btn.style.display = count > 0 ? '' : 'none';
|
||||||
|
btn.textContent = `\u26a0 Regen ${count} Stale`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Port rendering ──
|
// ── Port rendering ──
|
||||||
|
|
||||||
private renderPortsSvg(node: CampaignPlannerNode): string {
|
private renderPortsSvg(node: CampaignPlannerNode): string {
|
||||||
|
|
@ -1509,6 +1836,10 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
sourcePortKind = 'publish';
|
sourcePortKind = 'publish';
|
||||||
targetPortKind = 'target-in';
|
targetPortKind = 'target-in';
|
||||||
break;
|
break;
|
||||||
|
case 'feeds':
|
||||||
|
sourcePortKind = 'feeds-out';
|
||||||
|
targetPortKind = 'feeds-in';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
sourcePortKind = 'publish';
|
sourcePortKind = 'publish';
|
||||||
targetPortKind = 'content-in';
|
targetPortKind = 'content-in';
|
||||||
|
|
@ -1522,6 +1853,7 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
const style = EDGE_STYLES[edge.type] || { width: 2, dash: '', animated: false };
|
const style = EDGE_STYLES[edge.type] || { width: 2, dash: '', animated: false };
|
||||||
const cssClass = edge.type === 'publish' ? 'edge-path-publish'
|
const cssClass = edge.type === 'publish' ? 'edge-path-publish'
|
||||||
: edge.type === 'sequence' ? 'edge-path-sequence'
|
: edge.type === 'sequence' ? 'edge-path-sequence'
|
||||||
|
: edge.type === 'feeds' ? 'edge-path-feeds'
|
||||||
: 'edge-path-target';
|
: 'edge-path-target';
|
||||||
|
|
||||||
// Build bezier path
|
// Build bezier path
|
||||||
|
|
@ -1602,6 +1934,38 @@ class FolkCampaignPlanner extends HTMLElement {
|
||||||
this.addNode('audience', cx, cy);
|
this.addNode('audience', cx, cy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brief panel
|
||||||
|
this.shadow.getElementById('toggle-brief')?.addEventListener('click', () => {
|
||||||
|
this.briefPanelOpen = !this.briefPanelOpen;
|
||||||
|
const panel = this.shadow.getElementById('brief-panel');
|
||||||
|
if (panel) panel.classList.toggle('open', this.briefPanelOpen);
|
||||||
|
});
|
||||||
|
this.shadow.getElementById('close-brief')?.addEventListener('click', () => {
|
||||||
|
this.briefPanelOpen = false;
|
||||||
|
const panel = this.shadow.getElementById('brief-panel');
|
||||||
|
if (panel) panel.classList.remove('open');
|
||||||
|
});
|
||||||
|
this.shadow.getElementById('brief-text')?.addEventListener('input', (e) => {
|
||||||
|
this.briefText = (e.target as HTMLTextAreaElement).value;
|
||||||
|
});
|
||||||
|
this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const checked: string[] = [];
|
||||||
|
this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]:checked').forEach(c => {
|
||||||
|
checked.push((c as HTMLInputElement).value);
|
||||||
|
});
|
||||||
|
this.briefPlatforms = checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.shadow.getElementById('brief-generate')?.addEventListener('click', () => {
|
||||||
|
this.generateFromBrief();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regen stale
|
||||||
|
this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => {
|
||||||
|
this.regenStaleNodes();
|
||||||
|
});
|
||||||
|
|
||||||
// Postiz panel
|
// Postiz panel
|
||||||
this.shadow.getElementById('toggle-postiz')?.addEventListener('click', () => {
|
this.shadow.getElementById('toggle-postiz')?.addEventListener('click', () => {
|
||||||
this.postizOpen = !this.postizOpen;
|
this.postizOpen = !this.postizOpen;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module";
|
||||||
import type { SyncServer } from "../../server/local-first/sync-server";
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
|
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
|
||||||
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft } from "./schemas";
|
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft, type CampaignPlannerNode, type CampaignEdge, type CampaignFlow, type GoalNodeData, type MessageNodeData, type ToneNodeData, type PostNodeData, type PhaseNodeData, type PlatformNodeData } from "./schemas";
|
||||||
import {
|
import {
|
||||||
generateImageFromPrompt,
|
generateImageFromPrompt,
|
||||||
downloadAndSaveImage,
|
downloadAndSaveImage,
|
||||||
|
|
@ -1913,8 +1913,413 @@ routes.delete("/api/campaign/wizard/:id", (c) => {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Campaign Flow (graph-based) API ──
|
||||||
|
|
||||||
|
routes.post("/api/campaign/flow/from-brief", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
|
||||||
|
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { rawBrief, platforms } = body;
|
||||||
|
if (!rawBrief || typeof rawBrief !== "string" || rawBrief.trim().length < 10) {
|
||||||
|
return c.json({ error: "rawBrief required (min 10 characters)" }, 400);
|
||||||
|
}
|
||||||
|
const selectedPlatforms: string[] = Array.isArray(platforms) && platforms.length > 0
|
||||||
|
? platforms
|
||||||
|
: ["x", "linkedin", "instagram", "threads", "bluesky"];
|
||||||
|
|
||||||
|
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||||
|
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||||
|
const model = genAI.getGenerativeModel({
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
generationConfig: { responseMimeType: "application/json" } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `You are a social media campaign strategist. Analyze this campaign brief and return structured campaign data.
|
||||||
|
|
||||||
|
Brief:
|
||||||
|
"""
|
||||||
|
${rawBrief.trim()}
|
||||||
|
"""
|
||||||
|
|
||||||
|
Platforms to target: ${selectedPlatforms.join(', ')}
|
||||||
|
|
||||||
|
Return JSON:
|
||||||
|
{
|
||||||
|
"goal": { "title": "Campaign title", "objective": "What we want to achieve", "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" },
|
||||||
|
"messages": [
|
||||||
|
{ "text": "Key message 1", "priority": "high" },
|
||||||
|
{ "text": "Key message 2", "priority": "medium" }
|
||||||
|
],
|
||||||
|
"tone": { "tone": "professional | casual | urgent | inspirational", "style": "event-promo | product-launch | awareness | community", "audience": "Target audience description" },
|
||||||
|
"phases": [
|
||||||
|
{ "name": "phase-slug", "label": "Phase Label", "days": "Day 1-3", "color": "#hex" }
|
||||||
|
],
|
||||||
|
"posts": [
|
||||||
|
{ "platform": "x", "postType": "text", "content": "Post content", "scheduledAt": "2026-04-20T09:00:00", "hashtags": ["tag1"], "phase": 0, "phaseLabel": "Phase Label" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- 2-4 key messages, 3-5 phases
|
||||||
|
- Generate posts matching cadence for each phase × platform
|
||||||
|
- Use today (${new Date().toISOString().split('T')[0]}) as reference
|
||||||
|
- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300)
|
||||||
|
- Phase colors: use distinct hex colors (#6366f1, #10b981, #f59e0b, #ef4444, #8b5cf6)`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await model.generateContent(prompt);
|
||||||
|
const text = result.response.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const nodes: CampaignPlannerNode[] = [];
|
||||||
|
const edges: CampaignEdge[] = [];
|
||||||
|
|
||||||
|
// Col 1: Input nodes (x=50)
|
||||||
|
const goalId = `goal-${now}-g`;
|
||||||
|
nodes.push({
|
||||||
|
id: goalId,
|
||||||
|
type: 'goal',
|
||||||
|
position: { x: 50, y: 50 },
|
||||||
|
data: {
|
||||||
|
label: parsed.goal?.title || 'Campaign Goal',
|
||||||
|
objective: parsed.goal?.objective || '',
|
||||||
|
startDate: parsed.goal?.startDate || '',
|
||||||
|
endDate: parsed.goal?.endDate || '',
|
||||||
|
} as GoalNodeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageIds: string[] = [];
|
||||||
|
(parsed.messages || []).forEach((msg: any, i: number) => {
|
||||||
|
const mid = `message-${now}-${i}`;
|
||||||
|
messageIds.push(mid);
|
||||||
|
nodes.push({
|
||||||
|
id: mid,
|
||||||
|
type: 'message',
|
||||||
|
position: { x: 50, y: 220 + i * 130 },
|
||||||
|
data: {
|
||||||
|
label: (msg.text || '').substring(0, 40),
|
||||||
|
text: msg.text || '',
|
||||||
|
priority: msg.priority || 'medium',
|
||||||
|
} as MessageNodeData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneId = `tone-${now}-t`;
|
||||||
|
nodes.push({
|
||||||
|
id: toneId,
|
||||||
|
type: 'tone',
|
||||||
|
position: { x: 50, y: 220 + messageIds.length * 130 },
|
||||||
|
data: {
|
||||||
|
label: `${parsed.tone?.tone || 'Professional'} tone`,
|
||||||
|
tone: parsed.tone?.tone || 'professional',
|
||||||
|
style: parsed.tone?.style || 'awareness',
|
||||||
|
audience: parsed.tone?.audience || '',
|
||||||
|
sizeEstimate: '',
|
||||||
|
} as ToneNodeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Col 2: Phase containers (x=350)
|
||||||
|
const phaseIds: string[] = [];
|
||||||
|
(parsed.phases || []).forEach((phase: any, i: number) => {
|
||||||
|
const pid = `phase-${now}-${i}`;
|
||||||
|
phaseIds.push(pid);
|
||||||
|
nodes.push({
|
||||||
|
id: pid,
|
||||||
|
type: 'phase',
|
||||||
|
position: { x: 350, y: 50 + i * 320 },
|
||||||
|
data: {
|
||||||
|
label: phase.label || `Phase ${i + 1}`,
|
||||||
|
dateRange: phase.days || '',
|
||||||
|
color: phase.color || '#6366f1',
|
||||||
|
progress: 0,
|
||||||
|
childNodeIds: [],
|
||||||
|
size: { w: 400, h: 280 },
|
||||||
|
} as PhaseNodeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// goal → phase (feeds)
|
||||||
|
edges.push({
|
||||||
|
id: `e-${now}-goal-phase-${i}`,
|
||||||
|
from: goalId,
|
||||||
|
to: pid,
|
||||||
|
type: 'feeds',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Col 3: Post nodes (inside phases) + platform nodes (x=850)
|
||||||
|
const platformNodeMap = new Map<string, string>();
|
||||||
|
let postIdx = 0;
|
||||||
|
(parsed.posts || []).forEach((post: any) => {
|
||||||
|
const postId = `post-${now}-${postIdx++}`;
|
||||||
|
const phaseIdx = typeof post.phase === 'number' ? post.phase : 0;
|
||||||
|
const phaseY = 50 + phaseIdx * 320;
|
||||||
|
const postY = phaseY + 60 + (postIdx % 5) * 140;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: postId,
|
||||||
|
type: 'post',
|
||||||
|
position: { x: 400 + (postIdx % 3) * 260, y: postY },
|
||||||
|
data: {
|
||||||
|
label: (post.content || '').split('\n')[0].substring(0, 40) || 'Post',
|
||||||
|
platform: post.platform || 'x',
|
||||||
|
postType: post.postType || 'text',
|
||||||
|
content: post.content || '',
|
||||||
|
scheduledAt: post.scheduledAt || '',
|
||||||
|
status: 'draft',
|
||||||
|
hashtags: post.hashtags || [],
|
||||||
|
} as PostNodeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// message → post (feeds)
|
||||||
|
if (messageIds.length > 0) {
|
||||||
|
const msgIdx = postIdx % messageIds.length;
|
||||||
|
edges.push({
|
||||||
|
id: `e-${now}-msg-post-${postIdx}`,
|
||||||
|
from: messageIds[msgIdx],
|
||||||
|
to: postId,
|
||||||
|
type: 'feeds',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// tone → post (feeds)
|
||||||
|
edges.push({
|
||||||
|
id: `e-${now}-tone-post-${postIdx}`,
|
||||||
|
from: toneId,
|
||||||
|
to: postId,
|
||||||
|
type: 'feeds',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure platform node exists + post → platform (publish)
|
||||||
|
const plat = post.platform || 'x';
|
||||||
|
if (!platformNodeMap.has(plat)) {
|
||||||
|
const platId = `platform-${now}-${plat}`;
|
||||||
|
platformNodeMap.set(plat, platId);
|
||||||
|
const platIdx = platformNodeMap.size - 1;
|
||||||
|
nodes.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
edges.push({
|
||||||
|
id: `e-${now}-post-plat-${postIdx}`,
|
||||||
|
from: postId,
|
||||||
|
to: platformNodeMap.get(plat)!,
|
||||||
|
type: 'publish',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save flow to Automerge
|
||||||
|
const flow: CampaignFlow = {
|
||||||
|
id: flowId,
|
||||||
|
name: parsed.goal?.title || 'Campaign Flow',
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
ensureDoc(dataSpace);
|
||||||
|
_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;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(flow, 201);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[rSocials] Flow from-brief error:", e.message);
|
||||||
|
return c.json({ error: "Failed to generate flow: " + (e.message || "unknown") }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/campaign/flow/regen-nodes", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
|
||||||
|
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { flowId, nodeIds } = body;
|
||||||
|
if (!flowId || !Array.isArray(nodeIds) || nodeIds.length === 0) {
|
||||||
|
return c.json({ error: "flowId and nodeIds[] required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const flow = doc.campaignFlows?.[flowId];
|
||||||
|
if (!flow) return c.json({ error: "Flow not found" }, 404);
|
||||||
|
|
||||||
|
// Gather input context from upstream nodes via feeds edges
|
||||||
|
const inputNodes = flow.nodes.filter(n => n.type === 'goal' || n.type === 'message' || n.type === 'tone');
|
||||||
|
const inputContext = {
|
||||||
|
goals: inputNodes.filter(n => n.type === 'goal').map(n => n.data),
|
||||||
|
messages: inputNodes.filter(n => n.type === 'message').map(n => n.data),
|
||||||
|
tones: inputNodes.filter(n => n.type === 'tone').map(n => n.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetNodes = flow.nodes.filter(n => nodeIds.includes(n.id));
|
||||||
|
if (targetNodes.length === 0) return c.json({ error: "No matching nodes found" }, 404);
|
||||||
|
|
||||||
|
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||||
|
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||||
|
const model = genAI.getGenerativeModel({
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
generationConfig: { responseMimeType: "application/json" } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `Regenerate content for these social media posts based on updated campaign inputs.
|
||||||
|
|
||||||
|
Campaign inputs:
|
||||||
|
${JSON.stringify(inputContext, null, 2)}
|
||||||
|
|
||||||
|
Posts to regenerate (keep platform, postType, scheduledAt — only update content and hashtags):
|
||||||
|
${JSON.stringify(targetNodes.map(n => ({ id: n.id, type: n.type, data: n.data })), null, 2)}
|
||||||
|
|
||||||
|
Return JSON array with same structure, updated content:
|
||||||
|
[{ "id": "node-id", "content": "new content", "hashtags": ["tag"], "label": "short label" }]
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300)
|
||||||
|
- Incorporate the campaign goal, key messages, and tone
|
||||||
|
- Keep scheduling and platform unchanged`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await model.generateContent(prompt);
|
||||||
|
const text = result.response.text();
|
||||||
|
const updates = JSON.parse(text);
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, `regen stale nodes in ${flowId}`, (d) => {
|
||||||
|
const f = d.campaignFlows?.[flowId];
|
||||||
|
if (!f) return;
|
||||||
|
for (const upd of updates) {
|
||||||
|
const node = f.nodes.find((n: any) => n.id === upd.id);
|
||||||
|
if (!node) continue;
|
||||||
|
if (node.type === 'post') {
|
||||||
|
const pd = node.data as any;
|
||||||
|
if (upd.content) pd.content = upd.content;
|
||||||
|
if (upd.hashtags) pd.hashtags = upd.hashtags;
|
||||||
|
if (upd.label) pd.label = upd.label;
|
||||||
|
}
|
||||||
|
(node as any).stale = false;
|
||||||
|
(node as any).staleReason = '';
|
||||||
|
}
|
||||||
|
f.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
|
||||||
|
return c.json({ flow: updated.campaignFlows[flowId] });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[rSocials] Flow regen error:", e.message);
|
||||||
|
return c.json({ error: "Failed to regenerate: " + (e.message || "unknown") }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/campaign/flow/ai-fill-node", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
|
||||||
|
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { flowId, nodeId } = body;
|
||||||
|
if (!flowId || !nodeId) return c.json({ error: "flowId and nodeId required" }, 400);
|
||||||
|
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const flow = doc.campaignFlows?.[flowId];
|
||||||
|
if (!flow) return c.json({ error: "Flow not found" }, 404);
|
||||||
|
|
||||||
|
const node = flow.nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node) return c.json({ error: "Node not found" }, 404);
|
||||||
|
|
||||||
|
// Read upstream input nodes connected via feeds edges
|
||||||
|
const upstreamIds = flow.edges
|
||||||
|
.filter(e => e.to === nodeId && e.type === 'feeds')
|
||||||
|
.map(e => e.from);
|
||||||
|
const upstreamNodes = flow.nodes.filter(n => upstreamIds.includes(n.id));
|
||||||
|
|
||||||
|
const inputContext = {
|
||||||
|
goals: upstreamNodes.filter(n => n.type === 'goal').map(n => n.data),
|
||||||
|
messages: upstreamNodes.filter(n => n.type === 'message').map(n => n.data),
|
||||||
|
tones: upstreamNodes.filter(n => n.type === 'tone').map(n => n.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||||
|
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||||
|
const model = genAI.getGenerativeModel({
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
generationConfig: { responseMimeType: "application/json" } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `Generate content for this social media post based on campaign context.
|
||||||
|
|
||||||
|
Campaign inputs:
|
||||||
|
${JSON.stringify(inputContext, null, 2)}
|
||||||
|
|
||||||
|
Target node to fill:
|
||||||
|
${JSON.stringify({ id: node.id, type: node.type, data: node.data }, null, 2)}
|
||||||
|
|
||||||
|
Return JSON: { "content": "post content", "hashtags": ["tag"], "label": "short label" }
|
||||||
|
|
||||||
|
Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goal, messages, and tone.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await model.generateContent(prompt);
|
||||||
|
const text = result.response.text();
|
||||||
|
const generated = JSON.parse(text);
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, `ai fill node ${nodeId}`, (d) => {
|
||||||
|
const f = d.campaignFlows?.[flowId];
|
||||||
|
if (!f) return;
|
||||||
|
const n = f.nodes.find((nd: any) => nd.id === nodeId);
|
||||||
|
if (!n || n.type !== 'post') return;
|
||||||
|
const pd = n.data as any;
|
||||||
|
if (generated.content) pd.content = generated.content;
|
||||||
|
if (generated.hashtags) pd.hashtags = generated.hashtags;
|
||||||
|
if (generated.label) pd.label = generated.label;
|
||||||
|
(n as any).stale = false;
|
||||||
|
(n as any).staleReason = '';
|
||||||
|
f.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
|
||||||
|
const updatedNode = updated.campaignFlows[flowId]?.nodes.find(n => n.id === nodeId);
|
||||||
|
return c.json({ node: updatedNode });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[rSocials] AI fill error:", e.message);
|
||||||
|
return c.json({ error: "Failed to generate: " + (e.message || "unknown") }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Page routes (inject web components) ──
|
// ── Page routes (inject web components) ──
|
||||||
|
|
||||||
|
routes.get("/campaign-flow", (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
return c.html(renderShell({
|
||||||
|
title: `Campaign Flow — rSocials | rSpace`,
|
||||||
|
moduleId: "rsocials",
|
||||||
|
spaceSlug: space,
|
||||||
|
modules: getModuleInfoList(),
|
||||||
|
theme: "dark",
|
||||||
|
body: `<folk-campaign-planner space="${escapeHtml(space)}"></folk-campaign-planner>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`,
|
||||||
|
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
routes.get("/campaign-wizard/:id", (c) => {
|
routes.get("/campaign-wizard/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const wizardId = c.req.param("id");
|
const wizardId = c.req.param("id");
|
||||||
|
|
|
||||||
|
|
@ -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';
|
export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone';
|
||||||
|
|
||||||
export interface PostNodeData {
|
export interface PostNodeData {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -97,14 +97,37 @@ export interface PhaseNodeData {
|
||||||
size: { w: number; h: number };
|
size: { w: number; h: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoalNodeData {
|
||||||
|
label: string;
|
||||||
|
objective: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageNodeData {
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToneNodeData {
|
||||||
|
label: string;
|
||||||
|
tone: string;
|
||||||
|
style: string;
|
||||||
|
audience: string;
|
||||||
|
sizeEstimate: string;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData;
|
||||||
|
stale?: boolean;
|
||||||
|
staleReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CampaignEdgeType = 'publish' | 'sequence' | 'target';
|
export type CampaignEdgeType = 'publish' | 'sequence' | 'target' | 'feeds';
|
||||||
|
|
||||||
export interface CampaignEdge {
|
export interface CampaignEdge {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -455,12 +478,12 @@ export interface SocialsDoc {
|
||||||
export const socialsSchema: DocSchema<SocialsDoc> = {
|
export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||||
module: 'socials',
|
module: 'socials',
|
||||||
collection: 'data',
|
collection: 'data',
|
||||||
version: 7,
|
version: 8,
|
||||||
init: (): SocialsDoc => ({
|
init: (): SocialsDoc => ({
|
||||||
meta: {
|
meta: {
|
||||||
module: 'socials',
|
module: 'socials',
|
||||||
collection: 'data',
|
collection: 'data',
|
||||||
version: 7,
|
version: 8,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|
@ -480,7 +503,7 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||||
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
|
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
|
||||||
if (!doc.campaignWizards) (doc as any).campaignWizards = {};
|
if (!doc.campaignWizards) (doc as any).campaignWizards = {};
|
||||||
if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {};
|
if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {};
|
||||||
if (doc.meta) doc.meta.version = 7;
|
if (doc.meta) doc.meta.version = 8;
|
||||||
return doc;
|
return doc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue