feat(rsocials): add campaigns dashboard with workflow thumbnail grid
New gallery landing page at /campaigns showing all campaign workflows as cards with miniature SVG previews. Click a card to open the editor at ?workflow=<id>. Editor gains back-link to dashboard and workflow attribute for deep-linking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
39ec09bb3b
commit
8bd6e61ffc
|
|
@ -73,6 +73,19 @@ folk-campaign-workflow {
|
|||
border-color: var(--rs-border-strong, #4d4d6c);
|
||||
}
|
||||
|
||||
.cw-btn--back {
|
||||
text-decoration: none;
|
||||
color: #93c5fd;
|
||||
border-color: #3b82f644;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.cw-btn--back:hover {
|
||||
background: #3b82f622;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.cw-btn--run {
|
||||
background: #3b82f622;
|
||||
border-color: #3b82f655;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class FolkCampaignWorkflow extends HTMLElement {
|
|||
}
|
||||
|
||||
// Data
|
||||
private targetWorkflowId = '';
|
||||
private workflows: CampaignWorkflow[] = [];
|
||||
private currentWorkflowId = '';
|
||||
private nodes: CampaignWorkflowNode[] = [];
|
||||
|
|
@ -134,8 +135,11 @@ class FolkCampaignWorkflow extends HTMLElement {
|
|||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
static get observedAttributes() { return ['space', 'workflow']; }
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute('space') || 'demo';
|
||||
this.targetWorkflowId = this.getAttribute('workflow') || '';
|
||||
this.initData();
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +159,10 @@ class FolkCampaignWorkflow extends HTMLElement {
|
|||
const data = await res.json();
|
||||
this.workflows = data.results || [];
|
||||
if (this.workflows.length > 0) {
|
||||
this.loadWorkflow(this.workflows[0]);
|
||||
const target = this.targetWorkflowId
|
||||
? this.workflows.find(w => w.id === this.targetWorkflowId)
|
||||
: undefined;
|
||||
this.loadWorkflow(target || this.workflows[0]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -306,7 +313,7 @@ class FolkCampaignWorkflow extends HTMLElement {
|
|||
<div class="cw-root">
|
||||
<div class="cw-toolbar">
|
||||
<div class="cw-toolbar__title">
|
||||
<span>Campaigns</span>
|
||||
<a id="btn-back" href="${this.basePath}campaigns" class="cw-btn cw-btn--back">\u2190 Dashboard</a>
|
||||
<select class="cw-workflow-select" id="workflow-select">
|
||||
${this.workflows.map(w => `<option value="${w.id}" ${w.id === this.currentWorkflowId ? 'selected' : ''}>${esc(w.name)}</option>`).join('')}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* <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 { 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 ──
|
||||
|
||||
class FolkCampaignsDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = '';
|
||||
private workflows: CampaignWorkflow[] = [];
|
||||
private loading = true;
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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">Create your first workflow to automate social media campaigns</div>
|
||||
<button class="cd-btn cd-btn--primary cd-btn--new-empty">+ New Workflow</button>
|
||||
</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: #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: #3b82f6; color: #fff; }
|
||||
.cd-btn--primary:hover { background: #2563eb; }
|
||||
|
||||
.cd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cd-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
|
||||
overflow: hidden; cursor: pointer; transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.cd-card:hover { border-color: #3b82f6; transform: translateY(-2px); }
|
||||
|
||||
.cd-card__preview {
|
||||
height: 160px; background: #14141e; display: flex; align-items: center; justify-content: center;
|
||||
border-bottom: 1px solid #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: #065f46; color: #6ee7b7; }
|
||||
.cd-badge--disabled { background: #3f3f46; color: #a1a1aa; }
|
||||
.cd-badge--success { background: #065f46; color: #6ee7b7; }
|
||||
.cd-badge--error { background: #7f1d1d; color: #fca5a5; }
|
||||
.cd-badge--nodes { background: #1e3a5f; color: #93c5fd; }
|
||||
.cd-badge--runs { background: #3b1f5e; color: #c4b5fd; }
|
||||
|
||||
.cd-card__date { font-size: 0.75rem; color: #888; }
|
||||
|
||||
.cd-empty {
|
||||
text-align: center; padding: 4rem 2rem;
|
||||
background: #1e1e2e; border: 1px dashed #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: #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: #888; }
|
||||
</style>
|
||||
|
||||
<div class="cd-root">
|
||||
<div class="cd-header">
|
||||
<h2>Campaign Workflows</h2>
|
||||
${!this.loading && this.workflows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Workflow</button>' : ''}
|
||||
</div>
|
||||
${loadingState}
|
||||
${emptyState}
|
||||
${!this.loading && this.workflows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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');
|
||||
if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createWorkflow());
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('folk-campaigns-dashboard', FolkCampaignsDashboard);
|
||||
|
|
@ -942,15 +942,30 @@ routes.get("/threads", (c) => {
|
|||
|
||||
routes.get("/campaigns", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const workflowId = c.req.query("workflow");
|
||||
|
||||
if (workflowId) {
|
||||
return c.html(renderShell({
|
||||
title: `Campaign Workflows — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<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",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-campaign-workflow space="${escapeHtml(space)}"></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>`,
|
||||
body: `<folk-campaigns-dashboard space="${escapeHtml(space)}"></folk-campaigns-dashboard>`,
|
||||
styles: ``,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaigns-dashboard.js"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -924,6 +924,31 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rsocials/campaign-workflow.css"),
|
||||
);
|
||||
|
||||
// Build campaigns dashboard component
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rsocials/components"),
|
||||
resolve: {
|
||||
alias: {
|
||||
"../schemas": resolve(__dirname, "modules/rsocials/schemas.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rsocials"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rsocials/components/folk-campaigns-dashboard.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-campaigns-dashboard.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "folk-campaigns-dashboard.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Build newsletter manager component
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue