rspace-online/modules/rsocials/components/folk-campaigns-dashboard.ts

360 lines
14 KiB
TypeScript

/**
* <folk-campaigns-dashboard> — Campaign workflow gallery grid.
*
* Fetches all campaign workflows and renders them as cards with miniature
* SVG previews of the node graph. Click a card to open the workflow editor.
*
* Attributes:
* space — space slug (default "demo")
*/
import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine';
import { CAMPAIGN_NODE_CATALOG } from '../schemas';
import type {
CampaignWorkflowNodeDef,
CampaignWorkflowNodeCategory,
CampaignWorkflowNode,
CampaignWorkflowEdge,
CampaignWorkflow,
} from '../schemas';
// ── Constants (match folk-campaign-workflow.ts) ──
const NODE_WIDTH = 220;
const NODE_HEIGHT = 80;
const CATEGORY_COLORS: Record<CampaignWorkflowNodeCategory, string> = {
trigger: '#3b82f6',
delay: '#a855f7',
condition: '#f59e0b',
action: '#10b981',
};
function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined {
return CAMPAIGN_NODE_CATALOG.find(n => n.type === type);
}
function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number {
const def = getNodeDef(node.type);
if (!def) return node.position.y + NODE_HEIGHT / 2;
const ports = direction === 'input' ? def.inputs : def.outputs;
const idx = ports.findIndex(p => p.name === portName);
if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
const spacing = NODE_HEIGHT / (ports.length + 1);
return node.position.y + spacing * (idx + 1);
}
function esc(s: string): string {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ── Mini SVG renderer ──
function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): string {
if (nodes.length === 0) {
return `<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>
</svg>`;
}
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
}
const pad = 20;
const vx = minX - pad;
const vy = minY - pad;
const vw = maxX - minX + pad * 2;
const vh = maxY - minY + pad * 2;
// Render edges as Bezier paths
const edgePaths = edges.map(e => {
const fromNode = nodes.find(n => n.id === e.fromNode);
const toNode = nodes.find(n => n.id === e.toNode);
if (!fromNode || !toNode) return '';
const x1 = fromNode.position.x + NODE_WIDTH;
const y1 = getPortY(fromNode, e.fromPort, 'output');
const x2 = toNode.position.x;
const y2 = getPortY(toNode, e.toPort, 'input');
const dx = Math.abs(x2 - x1) * 0.5;
return `<path d="M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}" fill="none" stroke="#555" stroke-width="2" opacity="0.6"/>`;
}).join('');
// Render nodes as rects
const nodeRects = nodes.map(n => {
const def = getNodeDef(n.type);
const cat = def?.category || 'action';
const color = CATEGORY_COLORS[cat] || '#666';
const label = def?.icon ? `${def.icon} ${esc(n.label || def.label)}` : esc(n.label || n.type);
// Truncate long labels for mini view
const shortLabel = label.length > 18 ? label.slice(0, 16) + '…' : label;
return `
<rect x="${n.position.x}" y="${n.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="8" fill="${color}" opacity="0.85"/>
<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>
`;
}).join('');
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" class="cd-card__svg">
${edgePaths}
${nodeRects}
</svg>`;
}
// ── Component ──
const DASHBOARD_TOUR_STEPS: TourStep[] = [
{ target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign creation flow — answer a few questions and get a ready-to-run workflow.' },
{ target: '#btn-new', title: 'New Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' },
{ target: '.cd-card', title: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' },
];
class FolkCampaignsDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private workflows: CampaignWorkflow[] = [];
private loading = true;
private _tour!: TourEngine;
private get basePath() {
const host = window.location.hostname;
if (host.endsWith('.rspace.online')) return '/rsocials/';
return `/${this.space}/rsocials/`;
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this._tour = new TourEngine(
this.shadow,
DASHBOARD_TOUR_STEPS,
'rsocials_dashboard_tour_done',
() => this.shadow.querySelector('.cd-root') as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.render();
this.loadWorkflows();
}
private async loadWorkflows() {
try {
const res = await fetch(`${this.basePath}api/campaign-workflows`);
if (res.ok) {
const data = await res.json();
this.workflows = data.results || [];
}
} catch {
console.warn('[CampaignsDashboard] Failed to load workflows');
}
this.loading = false;
this.render();
if (!localStorage.getItem('rsocials_dashboard_tour_done')) {
setTimeout(() => this._tour.start(), 800);
}
}
startTour() { this._tour.start(); }
private async createWorkflow() {
try {
const res = await fetch(`${this.basePath}api/campaign-workflows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Campaign Workflow' }),
});
if (res.ok) {
const wf = await res.json();
this.navigateToWorkflow(wf.id);
}
} catch {
console.error('[CampaignsDashboard] Failed to create workflow');
}
}
private navigateToWorkflow(id: string) {
const url = new URL(window.location.href);
url.searchParams.set('workflow', id);
window.location.href = url.toString();
}
private formatDate(ts: number | null): string {
if (!ts) return '—';
const d = new Date(ts);
const now = Date.now();
const diff = now - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
private render() {
const cards = this.workflows.map(wf => {
const nodeCount = wf.nodes.length;
const statusClass = wf.enabled ? 'cd-badge--enabled' : 'cd-badge--disabled';
const statusLabel = wf.enabled ? 'Enabled' : 'Disabled';
const runBadge = wf.lastRunStatus
? `<span class="cd-badge cd-badge--${wf.lastRunStatus === 'success' ? 'success' : 'error'}">${wf.lastRunStatus}</span>`
: '';
return `
<div class="cd-card" data-wf-id="${wf.id}">
<div class="cd-card__preview">
${renderMiniSVG(wf.nodes, wf.edges)}
</div>
<div class="cd-card__info">
<div class="cd-card__name">${esc(wf.name)}</div>
<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>
${runBadge}
${wf.runCount > 0 ? `<span class="cd-badge cd-badge--runs">${wf.runCount} run${wf.runCount !== 1 ? 's' : ''}</span>` : ''}
</div>
<div class="cd-card__date">Updated ${this.formatDate(wf.updatedAt)}</div>
</div>
</div>
`;
}).join('');
const emptyState = !this.loading && this.workflows.length === 0 ? `
<div class="cd-empty">
<div class="cd-empty__icon">📋</div>
<div class="cd-empty__title">No campaign workflows yet</div>
<div class="cd-empty__subtitle">Use the AI wizard for guided campaign creation, or start a blank workflow</div>
<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--primary cd-btn--new-empty">+ New Workflow</button>
</div>
</div>
` : '';
const loadingState = this.loading ? `
<div class="cd-loading">Loading workflows…</div>
` : '';
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--rs-text-primary, #e1e1e1); }
.cd-root { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.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-btn {
border: none; border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer;
font-size: 0.85rem; font-weight: 500; transition: background 0.15s;
}
.cd-btn--primary { background: var(--rs-primary, #3b82f6); color: #fff; }
.cd-btn--primary:hover { background: var(--rs-primary-hover, #2563eb); }
.cd-btn--wizard {
background: linear-gradient(135deg, var(--rs-accent, #14b8a6), #0d9488);
color: #fff; font-weight: 600;
}
.cd-btn--wizard:hover { opacity: 0.9; }
.cd-header__actions { display: flex; gap: 0.5rem; align-items: center; }
.cd-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.cd-card {
background: var(--rs-bg-surface, #1e1e2e); border: 1px solid var(--rs-border, #333); border-radius: 10px;
overflow: hidden; cursor: pointer; transition: border-color 0.15s, transform 0.15s;
}
.cd-card:hover { border-color: var(--rs-primary, #3b82f6); transform: translateY(-2px); }
.cd-card__preview {
height: 160px; background: var(--rs-bg-surface-sunken, #14141e); display: flex; align-items: center; justify-content: center;
border-bottom: 1px solid var(--rs-border, #333); padding: 0.5rem;
}
.cd-card__svg { width: 100%; height: 100%; }
.cd-card__info { padding: 0.75rem 1rem; }
.cd-card__name { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cd-card__badges { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.35rem; }
.cd-badge {
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px;
font-weight: 500; text-transform: capitalize;
}
.cd-badge--enabled { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); }
.cd-badge--disabled { background: var(--rs-bg-surface-raised, #3f3f46); color: var(--rs-text-muted, #a1a1aa); }
.cd-badge--success { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); }
.cd-badge--error { background: rgba(239, 68, 68, 0.15); color: var(--rs-error, #fca5a5); }
.cd-badge--nodes { background: rgba(59, 130, 246, 0.15); color: var(--rs-primary, #93c5fd); }
.cd-badge--runs { background: rgba(99, 102, 241, 0.15); color: var(--rs-primary-hover, #c4b5fd); }
.cd-card__date { font-size: 0.75rem; color: var(--rs-text-muted, #888); }
.cd-empty {
text-align: center; padding: 4rem 2rem;
background: var(--rs-bg-surface, #1e1e2e); border: 1px dashed var(--rs-border, #444); border-radius: 10px;
}
.cd-empty__icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
.cd-empty__title { font-size: 1.2rem; font-weight: 600; margin-bottom: 0.35rem; }
.cd-empty__subtitle { color: var(--rs-text-muted, #888); margin-bottom: 1.25rem; }
.cd-btn--new-empty { font-size: 0.95rem; padding: 0.6rem 1.5rem; }
.cd-loading { text-align: center; padding: 4rem; color: var(--rs-text-muted, #888); }
</style>
<div class="cd-root">
<div class="cd-header">
<h2>Campaign Workflows</h2>
<div class="cd-header__actions">
<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>' : ''}
<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>
${loadingState}
${emptyState}
${!this.loading && this.workflows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''}
</div>
`;
this._tour.renderOverlay();
this.attachListeners();
}
private attachListeners() {
// Card clicks
this.shadow.querySelectorAll('.cd-card').forEach(card => {
card.addEventListener('click', () => {
const id = (card as HTMLElement).dataset.wfId;
if (id) this.navigateToWorkflow(id);
});
});
// New workflow buttons
const btnNew = this.shadow.getElementById('btn-new');
if (btnNew) btnNew.addEventListener('click', () => this.createWorkflow());
const btnNewEmpty = this.shadow.querySelector('.cd-btn--new-empty:not(#btn-wizard-empty)');
if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createWorkflow());
// Wizard buttons
const wizardUrl = `${this.basePath}campaign-wizard`;
const btnWizard = this.shadow.getElementById('btn-wizard');
if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; });
const btnWizardEmpty = this.shadow.getElementById('btn-wizard-empty');
if (btnWizardEmpty) btnWizardEmpty.addEventListener('click', () => { window.location.href = wizardUrl; });
this.shadow.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
}
}
customElements.define('folk-campaigns-dashboard', FolkCampaignsDashboard);