feat: add campaign strategy workflow builder with node graph and timeline views

Visual n8n-style campaign builder for designing multi-platform social media
flows. Includes drag-and-drop node graph (trigger/post/delay/condition nodes),
timeline view with platform lanes, Zustand state management with autosave,
and file-based campaign persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-26 06:42:43 +00:00
parent 6898fee809
commit 3e7e57dd79
35 changed files with 2460 additions and 6 deletions

View File

@ -42,7 +42,7 @@ COPY --from=builder /app/.next/static ./.next/static
COPY entrypoint.sh /app/entrypoint.sh
# Create data directory for zine storage
RUN mkdir -p /app/data/zines && chown -R nextjs:nodejs /app/data
RUN mkdir -p /app/data/zines /app/data/campaigns && chown -R nextjs:nodejs /app/data
# Set ownership and make entrypoint executable
RUN chmod +x /app/entrypoint.sh && chown -R nextjs:nodejs /app

259
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@tanstack/react-query": "^5.90.21",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@ -24,7 +25,8 @@
"tailwind-merge": "^3.4.0",
"viem": "^2.46.3",
"wagmi": "^3.5.0",
"zod": "^4.3.6"
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -3190,6 +3192,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3823,6 +3874,95 @@
}
}
},
"node_modules/@wagmi/core/node_modules/zustand": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
"integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/@xyflow/react": {
"version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.75",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.75",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/abitype": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
@ -4328,6 +4468,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -4399,6 +4545,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -8825,9 +9076,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
"integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"

View File

@ -11,6 +11,7 @@
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@tanstack/react-query": "^5.90.21",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@ -25,7 +26,8 @@
"tailwind-merge": "^3.4.0",
"viem": "^2.46.3",
"wagmi": "^3.5.0",
"zod": "^4.3.6"
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import {
getCampaign,
saveCampaign,
deleteCampaign,
} from "@/lib/campaign-storage";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const campaign = await getCampaign(id);
if (!campaign) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(campaign);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const existing = await getCampaign(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const updated = {
...existing,
...body,
id,
updatedAt: new Date().toISOString(),
};
await saveCampaign(updated);
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await deleteCampaign(id);
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";
import { listCampaigns, saveCampaign } from "@/lib/campaign-storage";
import type { Campaign } from "@/lib/types/campaign";
export async function GET() {
const campaigns = await listCampaigns();
return NextResponse.json(campaigns);
}
export async function POST(request: Request) {
const body = await request.json();
const now = new Date().toISOString();
const campaign: Campaign = {
id: nanoid(10),
name: body.name || "Untitled Campaign",
description: body.description || "",
status: "draft",
nodes: [],
edges: [],
createdAt: now,
updatedAt: now,
};
await saveCampaign(campaign);
return NextResponse.json(campaign, { status: 201 });
}

View File

@ -0,0 +1,11 @@
export default function CampaignEditorLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="fixed inset-0 top-[57px] z-30 bg-background">
{children}
</div>
);
}

View File

@ -0,0 +1,18 @@
import { notFound } from "next/navigation";
import { getCampaign } from "@/lib/campaign-storage";
import { CampaignEditor } from "@/components/campaigns/CampaignEditor";
export default async function CampaignEditorPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const campaign = await getCampaign(id);
if (!campaign) {
notFound();
}
return <CampaignEditor initialCampaign={campaign} />;
}

View File

@ -0,0 +1,7 @@
export default function CampaignsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@ -0,0 +1,10 @@
import { CampaignList } from "@/components/campaigns/CampaignList";
export const metadata = {
title: "Campaigns - rSocials",
description: "Design and schedule multi-platform campaign flows",
};
export default function CampaignsPage() {
return <CampaignList />;
}

View File

@ -45,6 +45,12 @@ export function Navbar() {
>
rZine
</Link>
<Link
href="/campaigns"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Campaigns
</Link>
</div>
</div>

View File

@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { LayoutGrid, Calendar, MoreHorizontal, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { Campaign } from "@/lib/types/campaign";
import { useState } from "react";
interface Props {
campaign: Campaign;
onDelete: (id: string) => void;
}
const statusVariants: Record<string, { label: string; className: string }> = {
draft: { label: "Draft", className: "bg-muted text-muted-foreground" },
scheduled: { label: "Scheduled", className: "bg-blue-500/10 text-blue-500 border-blue-500/20" },
running: { label: "Running", className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20" },
completed: { label: "Completed", className: "bg-green-500/10 text-green-500 border-green-500/20" },
paused: { label: "Paused", className: "bg-amber-500/10 text-amber-500 border-amber-500/20" },
};
export function CampaignCard({ campaign, onDelete }: Props) {
const [showMenu, setShowMenu] = useState(false);
const status = statusVariants[campaign.status] || statusVariants.draft;
return (
<div className="group relative rounded-xl border border-border bg-card hover:border-primary/30 hover:shadow-lg transition-all">
<Link href={`/campaigns/${campaign.id}`} className="block p-5">
<div className="flex items-start justify-between mb-3">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate pr-2">
{campaign.name}
</h3>
<Badge variant="outline" className={status.className}>
{status.label}
</Badge>
</div>
{campaign.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{campaign.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<LayoutGrid className="w-3.5 h-3.5" />
{campaign.nodes.length} node{campaign.nodes.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{new Date(campaign.updatedAt).toLocaleDateString()}
</span>
</div>
</Link>
<div className="absolute top-3 right-3">
<button
onClick={(e) => {
e.preventDefault();
setShowMenu(!showMenu);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted opacity-0 group-hover:opacity-100 transition-all"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-card shadow-lg py-1">
<button
onClick={(e) => {
e.preventDefault();
setShowMenu(false);
onDelete(campaign.id);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { Campaign } from "@/lib/types/campaign";
import { EditorToolbar } from "./EditorToolbar";
import { NodePalette } from "./NodePalette";
import { NodeDetailPanel } from "./NodeDetailPanel";
const CampaignGraph = dynamic(
() => import("./graph/CampaignGraph").then((m) => ({ default: m.CampaignGraph })),
{ ssr: false }
);
const CampaignTimeline = dynamic(
() => import("./timeline/CampaignTimeline").then((m) => ({ default: m.CampaignTimeline })),
{ ssr: false }
);
interface Props {
initialCampaign: Campaign;
}
export function CampaignEditor({ initialCampaign }: Props) {
const loadCampaign = useCampaignStore((s) => s.loadCampaign);
const view = useCampaignStore((s) => s.view);
useEffect(() => {
loadCampaign(initialCampaign);
}, [initialCampaign, loadCampaign]);
return (
<div className="flex flex-col h-[calc(100vh-64px)]">
<EditorToolbar />
<div className="flex flex-1 overflow-hidden">
{view === "graph" && <NodePalette />}
<div className="flex-1 relative">
{view === "graph" ? <CampaignGraph /> : <CampaignTimeline />}
</div>
{view === "graph" && <NodeDetailPanel />}
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Plus, Loader2, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CampaignCard } from "./CampaignCard";
import type { Campaign } from "@/lib/types/campaign";
export function CampaignList() {
const router = useRouter();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => {
fetch("/api/campaigns")
.then((r) => r.json())
.then(setCampaigns)
.finally(() => setLoading(false));
}, []);
const createCampaign = async () => {
setCreating(true);
try {
const res = await fetch("/api/campaigns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Untitled Campaign" }),
});
const campaign = await res.json();
router.push(`/campaigns/${campaign.id}`);
} finally {
setCreating(false);
}
};
const deleteCampaign = async (id: string) => {
await fetch(`/api/campaigns/${id}`, { method: "DELETE" });
setCampaigns((prev) => prev.filter((c) => c.id !== id));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">Campaigns</h1>
<p className="text-muted-foreground mt-1">
Design and schedule multi-platform campaign flows
</p>
</div>
<Button onClick={createCampaign} disabled={creating}>
{creating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
New Campaign
</Button>
</div>
{campaigns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Workflow className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">No campaigns yet</h2>
<p className="text-muted-foreground max-w-md mb-6">
Create your first campaign to design visual workflows that schedule posts across multiple platforms.
</p>
<Button onClick={createCampaign} disabled={creating}>
<Plus className="w-4 h-4" />
Create First Campaign
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaigns.map((c) => (
<CampaignCard key={c.id} campaign={c} onDelete={deleteCampaign} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Save, Loader2, LayoutGrid, Calendar } from "lucide-react";
import { useCampaignStore, type EditorView } from "@/lib/stores/campaign-store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function EditorToolbar() {
const campaign = useCampaignStore((s) => s.campaign);
const setCampaignName = useCampaignStore((s) => s.setCampaignName);
const view = useCampaignStore((s) => s.view);
const setView = useCampaignStore((s) => s.setView);
const isSaving = useCampaignStore((s) => s.isSaving);
const isDirty = useCampaignStore((s) => s.isDirty);
const save = useCampaignStore((s) => s.save);
if (!campaign) return null;
const statusColors: Record<string, string> = {
draft: "bg-muted text-muted-foreground",
scheduled: "bg-blue-500/10 text-blue-500",
running: "bg-emerald-500/10 text-emerald-500",
completed: "bg-green-500/10 text-green-500",
paused: "bg-amber-500/10 text-amber-500",
};
return (
<div className="flex items-center justify-between h-14 px-4 border-b border-border bg-card">
<div className="flex items-center gap-3">
<Link
href="/campaigns"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<input
type="text"
value={campaign.name}
onChange={(e) => setCampaignName(e.target.value)}
className="text-lg font-semibold bg-transparent text-foreground border-none outline-none max-w-[300px]"
/>
<Badge className={statusColors[campaign.status] || ""} variant="outline">
{campaign.status}
</Badge>
{isSaving && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</span>
)}
{!isSaving && isDirty && (
<span className="text-xs text-amber-500">Unsaved</span>
)}
{!isSaving && !isDirty && (
<span className="text-xs text-muted-foreground">Saved</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-border overflow-hidden">
<button
onClick={() => setView("graph")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
view === "graph"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
Graph
</button>
<button
onClick={() => setView("timeline")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
view === "timeline"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<Calendar className="w-3.5 h-3.5" />
Timeline
</button>
</div>
<Button size="sm" variant="outline" onClick={() => save()} disabled={isSaving || !isDirty}>
<Save className="w-4 h-4" />
Save
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { X, Trash2 } from "lucide-react";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import { TriggerForm } from "./details/TriggerForm";
import { PostForm } from "./details/PostForm";
import { DelayForm } from "./details/DelayForm";
import { ConditionForm } from "./details/ConditionForm";
import type {
TriggerData,
PostData,
DelayData,
ConditionData,
} from "@/lib/types/campaign";
export function NodeDetailPanel() {
const campaign = useCampaignStore((s) => s.campaign);
const selectedNodeId = useCampaignStore((s) => s.selectedNodeId);
const selectNode = useCampaignStore((s) => s.selectNode);
const removeNode = useCampaignStore((s) => s.removeNode);
const updateNode = useCampaignStore((s) => s.updateNode);
if (!selectedNodeId || !campaign) return null;
const node = campaign.nodes.find((n) => n.id === selectedNodeId);
if (!node) return null;
return (
<div className="w-80 border-l border-border bg-card flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<input
type="text"
value={node.label}
onChange={(e) => updateNode(node.id, { label: e.target.value })}
className="text-sm font-semibold bg-transparent text-foreground border-none outline-none flex-1"
/>
<div className="flex items-center gap-1">
<button
onClick={() => {
removeNode(node.id);
selectNode(null);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={() => selectNode(null)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{node.type === "trigger" && (
<TriggerForm nodeId={node.id} data={node.data as TriggerData} />
)}
{node.type === "post" && (
<PostForm nodeId={node.id} data={node.data as PostData} />
)}
{node.type === "delay" && (
<DelayForm nodeId={node.id} data={node.data as DelayData} />
)}
{node.type === "condition" && (
<ConditionForm nodeId={node.id} data={node.data as ConditionData} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { Play, Send, Clock, GitBranch, GripVertical } from "lucide-react";
import type { CampaignNodeType } from "@/lib/types/campaign";
import type { DragEvent } from "react";
const NODE_TYPES: {
type: CampaignNodeType;
label: string;
icon: React.ReactNode;
color: string;
description: string;
}[] = [
{
type: "trigger",
label: "Trigger",
icon: <Play className="w-4 h-4" />,
color: "#22c55e",
description: "Start the campaign flow",
},
{
type: "post",
label: "Post",
icon: <Send className="w-4 h-4" />,
color: "oklch(0.6 0.2 30)",
description: "Publish to platforms",
},
{
type: "delay",
label: "Delay",
icon: <Clock className="w-4 h-4" />,
color: "#f59e0b",
description: "Wait before next step",
},
{
type: "condition",
label: "Condition",
icon: <GitBranch className="w-4 h-4" />,
color: "#a855f7",
description: "Branch on metrics",
},
];
export function NodePalette() {
const onDragStart = (event: DragEvent, type: CampaignNodeType) => {
event.dataTransfer.setData("application/campaign-node-type", type);
event.dataTransfer.effectAllowed = "move";
};
return (
<div className="w-56 border-r border-border bg-card flex flex-col h-full">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Nodes</h3>
<p className="text-xs text-muted-foreground">Drag onto canvas</p>
</div>
<div className="p-3 space-y-2 flex-1 overflow-y-auto">
{NODE_TYPES.map((nt) => (
<div
key={nt.type}
draggable
onDragStart={(e) => onDragStart(e, nt.type)}
className="flex items-center gap-3 rounded-lg border border-border p-3 cursor-grab active:cursor-grabbing hover:border-primary/50 hover:bg-muted/50 transition-colors"
>
<GripVertical className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<div
className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0"
style={{ backgroundColor: nt.color + "20", color: nt.color }}
>
{nt.icon}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{nt.label}</div>
<div className="text-xs text-muted-foreground truncate">{nt.description}</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type {
ConditionData,
ConditionMetric,
ConditionOperator,
} from "@/lib/types/campaign";
interface Props {
nodeId: string;
data: ConditionData;
}
const METRICS: { value: ConditionMetric; label: string }[] = [
{ value: "likes", label: "Likes" },
{ value: "retweets", label: "Retweets / Shares" },
{ value: "comments", label: "Comments" },
{ value: "impressions", label: "Impressions" },
{ value: "clicks", label: "Link Clicks" },
{ value: "followers", label: "Follower Count" },
];
const OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: ">", label: ">" },
{ value: ">=", label: ">=" },
{ value: "<", label: "<" },
{ value: "<=", label: "<=" },
{ value: "==", label: "==" },
{ value: "!=", label: "!=" },
];
export function ConditionForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Metric</label>
<select
value={data.metric}
onChange={(e) => updateNodeData(nodeId, { metric: e.target.value })}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{METRICS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-sm font-medium text-foreground mb-1.5">Operator</label>
<select
value={data.operator}
onChange={(e) => updateNodeData(nodeId, { operator: e.target.value })}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{OPERATORS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-foreground mb-1.5">Threshold</label>
<input
type="number"
min={0}
value={data.threshold}
onChange={(e) => updateNodeData(nodeId, { threshold: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
If <strong>{data.metric} {data.operator} {data.threshold}</strong> true path, otherwise false path
</p>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { DelayData } from "@/lib/types/campaign";
interface Props {
nodeId: string;
data: DelayData;
}
export function DelayForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Duration</label>
<div className="flex gap-2">
<input
type="number"
min={1}
value={data.delayKind.amount}
onChange={(e) =>
updateNodeData(nodeId, {
delayKind: { ...data.delayKind, amount: parseInt(e.target.value) || 1 },
})
}
className="w-24 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<select
value={data.delayKind.unit}
onChange={(e) =>
updateNodeData(nodeId, {
delayKind: { ...data.delayKind, unit: e.target.value as "minutes" | "hours" | "days" },
})
}
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
</select>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { Plus, X } from "lucide-react";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { PostData, Platform, PostPlatformContent } from "@/lib/types/campaign";
import { PLATFORMS } from "@/lib/types/campaign";
interface Props {
nodeId: string;
data: PostData;
}
export function PostForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
const addPlatform = (platformId: Platform) => {
if (data.platforms.some((p) => p.platform === platformId)) return;
const newPlatforms: PostPlatformContent[] = [
...data.platforms,
{ platform: platformId, content: data.sharedContent },
];
updateNodeData(nodeId, { platforms: newPlatforms });
};
const removePlatform = (platformId: Platform) => {
updateNodeData(nodeId, {
platforms: data.platforms.filter((p) => p.platform !== platformId),
});
};
const updatePlatformContent = (platformId: Platform, content: string) => {
updateNodeData(nodeId, {
platforms: data.platforms.map((p) =>
p.platform === platformId ? { ...p, content } : p
),
});
};
const availablePlatforms = PLATFORMS.filter(
(p) => !data.platforms.some((dp) => dp.platform === p.id)
);
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Shared Content</label>
<textarea
value={data.sharedContent}
onChange={(e) => updateNodeData(nodeId, { sharedContent: e.target.value })}
placeholder="Write your post content..."
rows={3}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Platforms</label>
{data.platforms.map((pp) => {
const info = PLATFORMS.find((p) => p.id === pp.platform);
return (
<div key={pp.platform} className="mb-3 rounded-lg border border-border p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium" style={{ color: info?.color }}>
{info?.label}
</span>
<button
onClick={() => removePlatform(pp.platform)}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</button>
</div>
<textarea
value={pp.content}
onChange={(e) => updatePlatformContent(pp.platform, e.target.value)}
placeholder={`Custom content for ${info?.label}...`}
rows={2}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground resize-none focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
);
})}
{availablePlatforms.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{availablePlatforms.map((p) => (
<button
key={p.id}
onClick={() => addPlatform(p.id)}
className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" />
{p.label}
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { TriggerData, TriggerKind } from "@/lib/types/campaign";
interface Props {
nodeId: string;
data: TriggerData;
}
export function TriggerForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Trigger Type</label>
<select
value={data.triggerKind}
onChange={(e) => updateNodeData(nodeId, { triggerKind: e.target.value as TriggerKind })}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="manual">Manual</option>
<option value="scheduled">Scheduled</option>
<option value="cron">Cron Expression</option>
</select>
</div>
{data.triggerKind === "scheduled" && (
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Date & Time</label>
<input
type="datetime-local"
value={data.scheduledAt ? data.scheduledAt.slice(0, 16) : ""}
onChange={(e) =>
updateNodeData(nodeId, { scheduledAt: new Date(e.target.value).toISOString() })
}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
)}
{data.triggerKind === "cron" && (
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Cron Expression</label>
<input
type="text"
value={data.cronExpression || ""}
onChange={(e) => updateNodeData(nodeId, { cronExpression: e.target.value })}
placeholder="0 9 * * 1-5"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm font-mono text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<p className="text-xs text-muted-foreground mt-1">e.g. 0 9 * * 1-5 = weekdays at 9am</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,179 @@
"use client";
import { useCallback, useMemo, useRef, type DragEvent } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type OnNodesChange,
type OnEdgesChange,
type OnConnect,
type Connection,
applyNodeChanges,
applyEdgeChanges,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { CampaignNodeType } from "@/lib/types/campaign";
import { TriggerNode } from "./nodes/TriggerNode";
import { PostNode } from "./nodes/PostNode";
import { DelayNode } from "./nodes/DelayNode";
import { ConditionNode } from "./nodes/ConditionNode";
import { CampaignEdge } from "./edges/CampaignEdge";
const nodeTypes = {
trigger: TriggerNode,
post: PostNode,
delay: DelayNode,
condition: ConditionNode,
};
const edgeTypes = {
campaign: CampaignEdge,
};
export function CampaignGraph() {
const campaign = useCampaignStore((s) => s.campaign);
const addNode = useCampaignStore((s) => s.addNode);
const addEdge = useCampaignStore((s) => s.addEdge);
const removeNode = useCampaignStore((s) => s.removeNode);
const removeEdge = useCampaignStore((s) => s.removeEdge);
const updateNodePosition = useCampaignStore((s) => s.updateNodePosition);
const selectNode = useCampaignStore((s) => s.selectNode);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const nodes: Node[] = useMemo(
() =>
(campaign?.nodes || []).map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: { label: n.label, nodeData: n.data },
})),
[campaign?.nodes]
);
const edges: Edge[] = useMemo(
() =>
(campaign?.edges || []).map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
type: "campaign",
label: e.label,
})),
[campaign?.edges]
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => {
// Handle position changes on drag stop
for (const change of changes) {
if (change.type === "position" && change.position && !change.dragging) {
updateNodePosition(change.id, change.position);
}
if (change.type === "remove") {
removeNode(change.id);
}
}
},
[updateNodePosition, removeNode]
);
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => {
for (const change of changes) {
if (change.type === "remove") {
removeEdge(change.id);
}
}
},
[removeEdge]
);
const onConnect: OnConnect = useCallback(
(connection: Connection) => {
addEdge({
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
});
},
[addEdge]
);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
selectNode(node.id);
},
[selectNode]
);
const onPaneClick = useCallback(() => {
selectNode(null);
}, [selectNode]);
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/campaign-node-type") as CampaignNodeType;
if (!type) return;
const bounds = reactFlowWrapper.current?.getBoundingClientRect();
if (!bounds) return;
const position = {
x: event.clientX - bounds.left - 90,
y: event.clientY - bounds.top - 30,
};
addNode(type, position);
},
[addNode]
);
return (
<div ref={reactFlowWrapper} className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onDragOver={onDragOver}
onDrop={onDrop}
fitView
snapToGrid
snapGrid={[20, 20]}
defaultEdgeOptions={{ type: "campaign" }}
deleteKeyCode={["Backspace", "Delete"]}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} className="!bg-background" />
<Controls className="!bg-card !border-border !shadow-lg [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground" />
<MiniMap
className="!bg-card !border-border"
nodeColor="oklch(0.6 0.2 30)"
maskColor="oklch(0.12 0.02 30 / 0.7)"
/>
</ReactFlow>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from "@xyflow/react";
export function CampaignEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
label,
style,
}: EdgeProps) {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<>
<BaseEdge
id={id}
path={edgePath}
style={{
stroke: "oklch(0.6 0.2 30)",
strokeWidth: 2,
...style,
}}
/>
{/* Animated flow dot */}
<circle r="3" fill="oklch(0.6 0.2 30)">
<animateMotion dur="2s" repeatCount="indefinite" path={edgePath} />
</circle>
{label && (
<text
x={labelX}
y={labelY - 10}
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
>
{String(label)}
</text>
)}
</>
);
}

View File

@ -0,0 +1,107 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { CampaignNodeType } from "@/lib/types/campaign";
import { useCampaignStore } from "@/lib/stores/campaign-store";
interface BaseNodeProps {
id: string;
label: string;
type: CampaignNodeType;
icon: React.ReactNode;
color: string;
hasInput?: boolean;
hasOutput?: boolean;
hasTrueOutput?: boolean;
hasFalseOutput?: boolean;
children?: React.ReactNode;
}
export function BaseNode({
id,
label,
icon,
color,
hasInput = true,
hasOutput = true,
hasTrueOutput = false,
hasFalseOutput = false,
children,
}: BaseNodeProps) {
const selectedNodeId = useCampaignStore((s) => s.selectedNodeId);
const isSelected = selectedNodeId === id;
return (
<div
className={`
relative min-w-[180px] rounded-xl border-2 bg-card shadow-lg
transition-all duration-150
${isSelected ? "ring-2 ring-primary/50 scale-[1.02]" : "hover:shadow-xl"}
`}
style={{ borderColor: color }}
>
{hasInput && (
<Handle
type="target"
position={Position.Left}
className="!w-3 !h-3 !border-2 !border-card"
style={{ background: color }}
/>
)}
<div className="px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<div
className="flex items-center justify-center w-7 h-7 rounded-lg"
style={{ backgroundColor: color + "20", color }}
>
{icon}
</div>
<span className="text-sm font-semibold text-foreground">{label}</span>
</div>
{children && (
<div className="text-xs text-muted-foreground mt-1">{children}</div>
)}
</div>
{hasOutput && !hasTrueOutput && (
<Handle
type="source"
position={Position.Right}
className="!w-3 !h-3 !border-2 !border-card"
style={{ background: color }}
/>
)}
{hasTrueOutput && (
<>
<Handle
type="source"
position={Position.Right}
id="true"
className="!w-3 !h-3 !border-2 !border-card"
style={{ background: "#22c55e", top: "35%" }}
/>
<span className="absolute right-5 text-[10px] text-emerald-500 font-medium" style={{ top: "27%" }}>
true
</span>
</>
)}
{hasFalseOutput && (
<>
<Handle
type="source"
position={Position.Right}
id="false"
className="!w-3 !h-3 !border-2 !border-card"
style={{ background: "#ef4444", top: "65%" }}
/>
<span className="absolute right-5 text-[10px] text-red-500 font-medium" style={{ top: "62%" }}>
false
</span>
</>
)}
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { GitBranch } from "lucide-react";
import { BaseNode } from "./BaseNode";
import type { ConditionData } from "@/lib/types/campaign";
interface Props {
id: string;
data: { label: string; nodeData: ConditionData };
}
export function ConditionNode({ id, data }: Props) {
return (
<BaseNode
id={id}
label={data.label}
type="condition"
icon={<GitBranch className="w-4 h-4" />}
color="#a855f7"
hasOutput={false}
hasTrueOutput
hasFalseOutput
>
{data.nodeData.metric} {data.nodeData.operator} {data.nodeData.threshold}
</BaseNode>
);
}

View File

@ -0,0 +1,25 @@
"use client";
import { Clock } from "lucide-react";
import { BaseNode } from "./BaseNode";
import type { DelayData } from "@/lib/types/campaign";
interface Props {
id: string;
data: { label: string; nodeData: DelayData };
}
export function DelayNode({ id, data }: Props) {
const { amount, unit } = data.nodeData.delayKind;
return (
<BaseNode
id={id}
label={data.label}
type="delay"
icon={<Clock className="w-4 h-4" />}
color="#f59e0b"
>
Wait {amount} {unit}
</BaseNode>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { Send } from "lucide-react";
import { BaseNode } from "./BaseNode";
import type { PostData, PLATFORMS } from "@/lib/types/campaign";
interface Props {
id: string;
data: { label: string; nodeData: PostData };
}
export function PostNode({ id, data }: Props) {
const platformCount = data.nodeData.platforms.length;
const contentPreview = data.nodeData.sharedContent
? data.nodeData.sharedContent.substring(0, 50) + (data.nodeData.sharedContent.length > 50 ? "..." : "")
: "No content yet";
return (
<BaseNode
id={id}
label={data.label}
type="post"
icon={<Send className="w-4 h-4" />}
color="oklch(0.6 0.2 30)"
>
<div>
{platformCount > 0 && (
<span className="text-primary font-medium">{platformCount} platform{platformCount !== 1 ? "s" : ""}</span>
)}
<div className="truncate">{contentPreview}</div>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { Play } from "lucide-react";
import { BaseNode } from "./BaseNode";
import type { TriggerData } from "@/lib/types/campaign";
interface Props {
id: string;
data: { label: string; nodeData: TriggerData };
}
export function TriggerNode({ id, data }: Props) {
const desc =
data.nodeData.triggerKind === "scheduled"
? data.nodeData.scheduledAt
? new Date(data.nodeData.scheduledAt).toLocaleString()
: "Set schedule..."
: data.nodeData.triggerKind === "cron"
? data.nodeData.cronExpression || "Set cron..."
: "Manual start";
return (
<BaseNode
id={id}
label={data.label}
type="trigger"
icon={<Play className="w-4 h-4" />}
color="#22c55e"
hasInput={false}
>
{desc}
</BaseNode>
);
}

View File

@ -0,0 +1,146 @@
"use client";
import { useMemo, useRef, useEffect, useState } from "react";
import { ZoomIn, ZoomOut } from "lucide-react";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { Platform, TimelineEntry } from "@/lib/types/campaign";
import { PLATFORMS } from "@/lib/types/campaign";
import { TimelineHeader } from "./TimelineHeader";
import { TimelineLane } from "./TimelineLane";
import { TimelineNowLine } from "./TimelineNowLine";
type Zoom = "hours" | "day" | "week";
const ZOOM_CONFIG: Record<Zoom, { msPerPixel: number; hours: number; label: string }> = {
hours: { msPerPixel: 6000, hours: 12, label: "12h" },
day: { msPerPixel: 30000, hours: 48, label: "2d" },
week: { msPerPixel: 120000, hours: 168, label: "7d" },
};
const OFFSET_LEFT = 0;
export function CampaignTimeline() {
const getTimelineEntries = useCampaignStore((s) => s.getTimelineEntries);
const campaign = useCampaignStore((s) => s.campaign);
const [zoom, setZoom] = useState<Zoom>("day");
const scrollRef = useRef<HTMLDivElement>(null);
const entries = useMemo(() => getTimelineEntries(), [getTimelineEntries, campaign]);
const config = ZOOM_CONFIG[zoom];
// Determine start time (earliest entry or now)
const startTime = useMemo(() => {
if (entries.length === 0) return new Date();
const earliest = Math.min(...entries.map((e) => e.startTime.getTime()));
const d = new Date(earliest);
d.setMinutes(0, 0, 0);
d.setHours(d.getHours() - 1);
return d;
}, [entries]);
// Group entries by platform
const platformLanes = useMemo(() => {
const lanes = new Map<Platform, TimelineEntry[]>();
for (const entry of entries) {
for (const platform of entry.platforms) {
if (!lanes.has(platform)) lanes.set(platform, []);
lanes.get(platform)!.push(entry);
}
}
// Also include platforms without entries if they appear in the list
return lanes;
}, [entries]);
// Scroll to now on mount
useEffect(() => {
if (scrollRef.current) {
const now = new Date();
const scrollTo = (now.getTime() - startTime.getTime()) / config.msPerPixel - 200;
scrollRef.current.scrollLeft = Math.max(0, scrollTo);
}
}, [startTime, config.msPerPixel]);
const totalWidth = (config.hours * 3600000) / config.msPerPixel;
const zoomLevels: Zoom[] = ["hours", "day", "week"];
const zoomIndex = zoomLevels.indexOf(zoom);
if (entries.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium">No timeline data yet</p>
<p className="text-sm mt-1">Add a Trigger node connected to Post nodes in the Graph view</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background">
{/* Zoom controls */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
<span className="text-sm text-muted-foreground">
{entries.length} post{entries.length !== 1 ? "s" : ""} across {platformLanes.size} platform{platformLanes.size !== 1 ? "s" : ""}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => zoomIndex > 0 && setZoom(zoomLevels[zoomIndex - 1])}
disabled={zoomIndex === 0}
className="p-1 rounded text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<ZoomIn className="w-4 h-4" />
</button>
<span className="text-xs font-medium text-foreground min-w-[30px] text-center">
{config.label}
</span>
<button
onClick={() => zoomIndex < zoomLevels.length - 1 && setZoom(zoomLevels[zoomIndex + 1])}
disabled={zoomIndex === zoomLevels.length - 1}
className="p-1 rounded text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<ZoomOut className="w-4 h-4" />
</button>
</div>
</div>
{/* Timeline body */}
<div ref={scrollRef} className="flex-1 overflow-auto relative">
<div style={{ width: 140 + totalWidth, minHeight: "100%" }}>
<div className="sticky top-0 z-10">
<div className="flex">
<div className="w-[140px] flex-shrink-0 bg-card border-r border-b border-border" />
<div className="flex-1 relative">
<TimelineHeader
startTime={startTime}
hours={config.hours}
msPerPixel={config.msPerPixel}
offsetLeft={OFFSET_LEFT}
/>
</div>
</div>
</div>
<div className="relative">
{Array.from(platformLanes.entries()).map(([platform, platformEntries]) => (
<TimelineLane
key={platform}
platform={platform}
entries={platformEntries}
startTime={startTime}
msPerPixel={config.msPerPixel}
offsetLeft={OFFSET_LEFT}
/>
))}
<TimelineNowLine
startTime={startTime}
msPerPixel={config.msPerPixel}
offsetLeft={140 + OFFSET_LEFT}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import { PLATFORMS, type Platform } from "@/lib/types/campaign";
interface Props {
nodeId: string;
label: string;
platforms: Platform[];
left: number;
width: number;
path: string;
}
export function TimelineEntryBar({ nodeId, label, platforms, left, width, path }: Props) {
const selectNode = useCampaignStore((s) => s.selectNode);
const setView = useCampaignStore((s) => s.setView);
const selectedNodeId = useCampaignStore((s) => s.selectedNodeId);
const isSelected = selectedNodeId === nodeId;
const platform = platforms[0];
const color = PLATFORMS.find((p) => p.id === platform)?.color || "oklch(0.6 0.2 30)";
return (
<button
onClick={() => {
selectNode(nodeId);
setView("graph");
}}
className={`
absolute h-8 rounded-md flex items-center gap-1.5 px-2 text-xs font-medium text-white
transition-all cursor-pointer hover:brightness-110 hover:scale-y-110
${isSelected ? "ring-2 ring-white/50 shadow-lg" : "shadow-sm"}
${path === "false" ? "opacity-60 border border-dashed border-white/30" : ""}
`}
style={{
left,
width: Math.max(width, 60),
backgroundColor: color,
top: "50%",
transform: "translateY(-50%)",
}}
title={`${label} (${platforms.join(", ")})`}
>
<span className="truncate">{label}</span>
{platforms.length > 1 && (
<span className="text-[10px] opacity-75">+{platforms.length - 1}</span>
)}
</button>
);
}

View File

@ -0,0 +1,37 @@
"use client";
interface Props {
startTime: Date;
hours: number;
msPerPixel: number;
offsetLeft: number;
}
export function TimelineHeader({ startTime, hours, msPerPixel, offsetLeft }: Props) {
const slots: { label: string; left: number }[] = [];
for (let h = 0; h < hours; h++) {
const time = new Date(startTime.getTime() + h * 3600000);
const left = offsetLeft + (h * 3600000) / msPerPixel;
const label =
h % 24 === 0
? time.toLocaleDateString("en-US", { month: "short", day: "numeric" })
: time.toLocaleTimeString("en-US", { hour: "numeric", hour12: true });
slots.push({ label, left });
}
return (
<div className="relative h-8 border-b border-border bg-muted/30 flex-shrink-0">
{slots.map((slot, i) => (
<div
key={i}
className="absolute text-[10px] text-muted-foreground whitespace-nowrap"
style={{ left: slot.left, top: "50%", transform: "translateY(-50%)" }}
>
{slot.label}
<div className="absolute left-0 top-4 w-px h-[calc(100vh-180px)] bg-border/50 pointer-events-none" />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import type { Platform, TimelineEntry } from "@/lib/types/campaign";
import { PLATFORMS } from "@/lib/types/campaign";
import { TimelineEntryBar } from "./TimelineEntry";
interface Props {
platform: Platform;
entries: TimelineEntry[];
startTime: Date;
msPerPixel: number;
offsetLeft: number;
}
export function TimelineLane({ platform, entries, startTime, msPerPixel, offsetLeft }: Props) {
const info = PLATFORMS.find((p) => p.id === platform);
return (
<div className="flex border-b border-border last:border-b-0">
<div className="w-[140px] flex-shrink-0 flex items-center gap-2 px-4 py-3 border-r border-border bg-card">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: info?.color }}
/>
<span className="text-xs font-medium text-foreground truncate">{info?.label || platform}</span>
</div>
<div className="flex-1 relative h-12">
{entries.map((entry) => {
const left = offsetLeft + (entry.startTime.getTime() - startTime.getTime()) / msPerPixel;
const width = (entry.endTime.getTime() - entry.startTime.getTime()) / msPerPixel;
return (
<TimelineEntryBar
key={entry.nodeId + entry.path}
nodeId={entry.nodeId}
label={entry.label}
platforms={entry.platforms}
left={left}
width={width}
path={entry.path}
/>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
interface Props {
startTime: Date;
msPerPixel: number;
offsetLeft: number;
}
export function TimelineNowLine({ startTime, msPerPixel, offsetLeft }: Props) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60000);
return () => clearInterval(interval);
}, []);
const left = offsetLeft + (now.getTime() - startTime.getTime()) / msPerPixel;
if (left < offsetLeft) return null;
return (
<div
className="absolute top-0 bottom-0 w-0.5 bg-destructive z-20 pointer-events-none"
style={{ left }}
>
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full bg-destructive" />
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] font-medium text-destructive whitespace-nowrap">
Now
</span>
</div>
);
}

View File

@ -0,0 +1,61 @@
import fs from "fs/promises";
import path from "path";
import type { Campaign } from "./types/campaign";
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const CAMPAIGNS_DIR = path.join(DATA_DIR, "campaigns");
async function ensureDir(dir: string): Promise<void> {
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
export async function saveCampaign(campaign: Campaign): Promise<void> {
const campaignDir = path.join(CAMPAIGNS_DIR, campaign.id);
await ensureDir(campaignDir);
const filepath = path.join(campaignDir, "campaign.json");
await fs.writeFile(filepath, JSON.stringify(campaign, null, 2));
}
export async function getCampaign(id: string): Promise<Campaign | null> {
try {
const filepath = path.join(CAMPAIGNS_DIR, id, "campaign.json");
const data = await fs.readFile(filepath, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
export async function listCampaigns(): Promise<Campaign[]> {
await ensureDir(CAMPAIGNS_DIR);
try {
const entries = await fs.readdir(CAMPAIGNS_DIR, { withFileTypes: true });
const campaigns: Campaign[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const campaign = await getCampaign(entry.name);
if (campaign) campaigns.push(campaign);
}
}
campaigns.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
return campaigns;
} catch {
return [];
}
}
export async function deleteCampaign(id: string): Promise<void> {
const campaignDir = path.join(CAMPAIGNS_DIR, id);
try {
await fs.rm(campaignDir, { recursive: true });
} catch {
// Ignore if doesn't exist
}
}

View File

@ -0,0 +1,330 @@
import { create } from "zustand";
import type {
Campaign,
CampaignNode,
CampaignEdge,
CampaignNodeType,
TimelineEntry,
PostNode,
DelayNode,
TriggerNode,
Platform,
} from "@/lib/types/campaign";
import {
defaultTriggerData as triggerDefault,
defaultPostData as postDefault,
defaultDelayData as delayDefault,
defaultConditionData as conditionDefault,
} from "@/lib/types/campaign";
import { nanoid } from "nanoid";
export type EditorView = "graph" | "timeline";
interface CampaignStore {
// Data
campaign: Campaign | null;
selectedNodeId: string | null;
view: EditorView;
isSaving: boolean;
isDirty: boolean;
// Campaign lifecycle
loadCampaign: (campaign: Campaign) => void;
setCampaignName: (name: string) => void;
setCampaignDescription: (description: string) => void;
// Node ops
addNode: (type: CampaignNodeType, position: { x: number; y: number }) => void;
updateNode: (id: string, updates: Partial<CampaignNode>) => void;
updateNodeData: (id: string, data: Record<string, unknown>) => void;
removeNode: (id: string) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
selectNode: (id: string | null) => void;
// Edge ops
addEdge: (edge: Omit<CampaignEdge, "id">) => void;
removeEdge: (id: string) => void;
// View
setView: (view: EditorView) => void;
// Persistence
save: () => Promise<void>;
// Timeline
getTimelineEntries: () => TimelineEntry[];
}
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
function debouncedSave(store: CampaignStore) {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
store.save();
}, 3000);
}
function hasCycle(
edges: CampaignEdge[],
newEdge: Omit<CampaignEdge, "id">
): boolean {
const adj = new Map<string, string[]>();
for (const e of edges) {
if (!adj.has(e.source)) adj.set(e.source, []);
adj.get(e.source)!.push(e.target);
}
if (!adj.has(newEdge.source)) adj.set(newEdge.source, []);
adj.get(newEdge.source)!.push(newEdge.target);
const visited = new Set<string>();
const stack = new Set<string>();
function dfs(node: string): boolean {
if (stack.has(node)) return true;
if (visited.has(node)) return false;
visited.add(node);
stack.add(node);
for (const neighbor of adj.get(node) || []) {
if (dfs(neighbor)) return true;
}
stack.delete(node);
return false;
}
for (const node of adj.keys()) {
if (dfs(node)) return true;
}
return false;
}
function nodeLabel(type: CampaignNodeType): string {
switch (type) {
case "trigger": return "Trigger";
case "post": return "Post";
case "delay": return "Delay";
case "condition": return "Condition";
}
}
function nodeDefaultData(type: CampaignNodeType) {
switch (type) {
case "trigger": return triggerDefault();
case "post": return postDefault();
case "delay": return delayDefault();
case "condition": return conditionDefault();
}
}
export const useCampaignStore = create<CampaignStore>((set, get) => ({
campaign: null,
selectedNodeId: null,
view: "graph",
isSaving: false,
isDirty: false,
loadCampaign: (campaign) => set({ campaign, isDirty: false, selectedNodeId: null }),
setCampaignName: (name) => {
const c = get().campaign;
if (!c) return;
set({ campaign: { ...c, name }, isDirty: true });
debouncedSave(get());
},
setCampaignDescription: (description) => {
const c = get().campaign;
if (!c) return;
set({ campaign: { ...c, description }, isDirty: true });
debouncedSave(get());
},
addNode: (type, position) => {
const c = get().campaign;
if (!c) return;
const node: CampaignNode = {
id: nanoid(8),
type,
position,
label: nodeLabel(type),
data: nodeDefaultData(type),
} as CampaignNode;
set({ campaign: { ...c, nodes: [...c.nodes, node] }, isDirty: true });
debouncedSave(get());
},
updateNode: (id, updates) => {
const c = get().campaign;
if (!c) return;
set({
campaign: {
...c,
nodes: c.nodes.map((n) =>
n.id === id ? ({ ...n, ...updates } as CampaignNode) : n
),
},
isDirty: true,
});
debouncedSave(get());
},
updateNodeData: (id, data) => {
const c = get().campaign;
if (!c) return;
set({
campaign: {
...c,
nodes: c.nodes.map((n) =>
n.id === id
? ({ ...n, data: { ...n.data, ...data } } as CampaignNode)
: n
),
},
isDirty: true,
});
debouncedSave(get());
},
removeNode: (id) => {
const c = get().campaign;
if (!c) return;
set({
campaign: {
...c,
nodes: c.nodes.filter((n) => n.id !== id),
edges: c.edges.filter((e) => e.source !== id && e.target !== id),
},
selectedNodeId: get().selectedNodeId === id ? null : get().selectedNodeId,
isDirty: true,
});
debouncedSave(get());
},
updateNodePosition: (id, position) => {
const c = get().campaign;
if (!c) return;
set({
campaign: {
...c,
nodes: c.nodes.map((n) =>
n.id === id ? ({ ...n, position } as CampaignNode) : n
),
},
isDirty: true,
});
debouncedSave(get());
},
selectNode: (id) => set({ selectedNodeId: id }),
addEdge: (edge) => {
const c = get().campaign;
if (!c) return;
if (hasCycle(c.edges, edge)) return;
// Prevent duplicate edges
if (c.edges.some((e) => e.source === edge.source && e.target === edge.target && e.sourceHandle === edge.sourceHandle)) return;
const newEdge: CampaignEdge = { ...edge, id: nanoid(8) };
set({ campaign: { ...c, edges: [...c.edges, newEdge] }, isDirty: true });
debouncedSave(get());
},
removeEdge: (id) => {
const c = get().campaign;
if (!c) return;
set({
campaign: { ...c, edges: c.edges.filter((e) => e.id !== id) },
isDirty: true,
});
debouncedSave(get());
},
setView: (view) => set({ view }),
save: async () => {
const c = get().campaign;
if (!c) return;
set({ isSaving: true });
try {
await fetch(`/api/campaigns/${c.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(c),
});
set({ isSaving: false, isDirty: false });
} catch {
set({ isSaving: false });
}
},
getTimelineEntries: () => {
const c = get().campaign;
if (!c) return [];
const entries: TimelineEntry[] = [];
const nodeMap = new Map(c.nodes.map((n) => [n.id, n]));
const adj = new Map<string, { target: string; sourceHandle?: string }[]>();
for (const e of c.edges) {
if (!adj.has(e.source)) adj.set(e.source, []);
adj.get(e.source)!.push({ target: e.target, sourceHandle: e.sourceHandle });
}
// Find trigger nodes as roots
const triggers = c.nodes.filter((n) => n.type === "trigger") as TriggerNode[];
for (const trigger of triggers) {
const triggerTime = trigger.data.scheduledAt
? new Date(trigger.data.scheduledAt)
: new Date();
// BFS from trigger
const queue: { nodeId: string; time: Date; path: string }[] = [];
for (const next of adj.get(trigger.id) || []) {
queue.push({ nodeId: next.target, time: triggerTime, path: "main" });
}
const visited = new Set<string>();
while (queue.length > 0) {
const { nodeId, time, path } = queue.shift()!;
if (visited.has(nodeId + path)) continue;
visited.add(nodeId + path);
const node = nodeMap.get(nodeId);
if (!node) continue;
if (node.type === "post") {
const postNode = node as PostNode;
const platforms: Platform[] = postNode.data.platforms.map((p) => p.platform);
entries.push({
nodeId: node.id,
label: node.label,
platforms: platforms.length > 0 ? platforms : ["twitter"],
startTime: new Date(time),
endTime: new Date(time.getTime() + 30 * 60 * 1000),
path,
});
// Continue to next nodes
for (const next of adj.get(nodeId) || []) {
queue.push({ nodeId: next.target, time: new Date(time.getTime() + 30 * 60 * 1000), path });
}
} else if (node.type === "delay") {
const delayNode = node as DelayNode;
const { amount, unit } = delayNode.data.delayKind;
let ms = amount * 60 * 1000;
if (unit === "hours") ms = amount * 60 * 60 * 1000;
if (unit === "days") ms = amount * 24 * 60 * 60 * 1000;
const afterDelay = new Date(time.getTime() + ms);
for (const next of adj.get(nodeId) || []) {
queue.push({ nodeId: next.target, time: afterDelay, path });
}
} else if (node.type === "condition") {
for (const next of adj.get(nodeId) || []) {
const branchPath = next.sourceHandle === "false" ? "false" : "true";
queue.push({ nodeId: next.target, time, path: branchPath });
}
}
}
}
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
return entries;
},
}));

150
src/lib/types/campaign.ts Normal file
View File

@ -0,0 +1,150 @@
// ─── Platforms ────────────────────────────────────────────
export type Platform =
| "twitter"
| "linkedin"
| "instagram"
| "facebook"
| "threads"
| "bluesky"
| "mastodon";
export const PLATFORMS: { id: Platform; label: string; color: string }[] = [
{ id: "twitter", label: "X / Twitter", color: "#1DA1F2" },
{ id: "linkedin", label: "LinkedIn", color: "#0A66C2" },
{ id: "instagram", label: "Instagram", color: "#E4405F" },
{ id: "facebook", label: "Facebook", color: "#1877F2" },
{ id: "threads", label: "Threads", color: "#000000" },
{ id: "bluesky", label: "Bluesky", color: "#0085FF" },
{ id: "mastodon", label: "Mastodon", color: "#6364FF" },
];
// ─── Node data payloads ──────────────────────────────────
export type TriggerKind = "scheduled" | "cron" | "manual";
export interface TriggerData {
triggerKind: TriggerKind;
scheduledAt?: string;
cronExpression?: string;
}
export interface PostPlatformContent {
platform: Platform;
content: string;
mediaUrls?: string[];
}
export interface PostData {
sharedContent: string;
platforms: PostPlatformContent[];
}
export interface DelayKind {
unit: "minutes" | "hours" | "days";
amount: number;
}
export interface DelayData {
delayKind: DelayKind;
}
export type ConditionOperator = ">" | "<" | ">=" | "<=" | "==" | "!=";
export type ConditionMetric =
| "likes"
| "retweets"
| "comments"
| "impressions"
| "clicks"
| "followers";
export interface ConditionData {
metric: ConditionMetric;
operator: ConditionOperator;
threshold: number;
}
// ─── Node types ──────────────────────────────────────────
export type CampaignNodeType = "trigger" | "post" | "delay" | "condition";
export interface CampaignNodeBase {
id: string;
type: CampaignNodeType;
position: { x: number; y: number };
label: string;
}
export interface TriggerNode extends CampaignNodeBase {
type: "trigger";
data: TriggerData;
}
export interface PostNode extends CampaignNodeBase {
type: "post";
data: PostData;
}
export interface DelayNode extends CampaignNodeBase {
type: "delay";
data: DelayData;
}
export interface ConditionNode extends CampaignNodeBase {
type: "condition";
data: ConditionData;
}
export type CampaignNode =
| TriggerNode
| PostNode
| DelayNode
| ConditionNode;
// ─── Edges ───────────────────────────────────────────────
export interface CampaignEdge {
id: string;
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
label?: string;
}
// ─── Timeline ────────────────────────────────────────────
export interface TimelineEntry {
nodeId: string;
label: string;
platforms: Platform[];
startTime: Date;
endTime: Date;
path: string;
}
// ─── Campaign ────────────────────────────────────────────
export type CampaignStatus = "draft" | "scheduled" | "running" | "completed" | "paused";
export interface Campaign {
id: string;
name: string;
description: string;
status: CampaignStatus;
nodes: CampaignNode[];
edges: CampaignEdge[];
createdAt: string;
updatedAt: string;
}
// ─── Default data factories ──────────────────────────────
export function defaultTriggerData(): TriggerData {
return { triggerKind: "manual" };
}
export function defaultPostData(): PostData {
return { sharedContent: "", platforms: [] };
}
export function defaultDelayData(): DelayData {
return { delayKind: { unit: "hours", amount: 1 } };
}
export function defaultConditionData(): ConditionData {
return { metric: "likes", operator: ">", threshold: 100 };
}