diff --git a/Dockerfile b/Dockerfile
index 6680e04..171524a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/package-lock.json b/package-lock.json
index c44e073..f54f6ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/package.json b/package.json
index bdb8b9c..c811ecd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts
new file mode 100644
index 0000000..316a953
--- /dev/null
+++ b/src/app/api/campaigns/[id]/route.ts
@@ -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 });
+}
diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts
new file mode 100644
index 0000000..9495a23
--- /dev/null
+++ b/src/app/api/campaigns/route.ts
@@ -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 });
+}
diff --git a/src/app/campaigns/[id]/layout.tsx b/src/app/campaigns/[id]/layout.tsx
new file mode 100644
index 0000000..4892681
--- /dev/null
+++ b/src/app/campaigns/[id]/layout.tsx
@@ -0,0 +1,11 @@
+export default function CampaignEditorLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/campaigns/[id]/page.tsx b/src/app/campaigns/[id]/page.tsx
new file mode 100644
index 0000000..1325c17
--- /dev/null
+++ b/src/app/campaigns/[id]/page.tsx
@@ -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 ;
+}
diff --git a/src/app/campaigns/layout.tsx b/src/app/campaigns/layout.tsx
new file mode 100644
index 0000000..b8bd86d
--- /dev/null
+++ b/src/app/campaigns/layout.tsx
@@ -0,0 +1,7 @@
+export default function CampaignsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return <>{children}>;
+}
diff --git a/src/app/campaigns/page.tsx b/src/app/campaigns/page.tsx
new file mode 100644
index 0000000..59cd3a9
--- /dev/null
+++ b/src/app/campaigns/page.tsx
@@ -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 ;
+}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index deca085..65cf3c9 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -45,6 +45,12 @@ export function Navbar() {
>
rZine
+
+ Campaigns
+
diff --git a/src/components/campaigns/CampaignCard.tsx b/src/components/campaigns/CampaignCard.tsx
new file mode 100644
index 0000000..6a09eef
--- /dev/null
+++ b/src/components/campaigns/CampaignCard.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+ {campaign.name}
+
+
+ {status.label}
+
+
+
+ {campaign.description && (
+
+ {campaign.description}
+
+ )}
+
+
+
+
+ {campaign.nodes.length} node{campaign.nodes.length !== 1 ? "s" : ""}
+
+
+
+ {new Date(campaign.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
+ {showMenu && (
+ <>
+
setShowMenu(false)} />
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/campaigns/CampaignEditor.tsx b/src/components/campaigns/CampaignEditor.tsx
new file mode 100644
index 0000000..e4c66ad
--- /dev/null
+++ b/src/components/campaigns/CampaignEditor.tsx
@@ -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 (
+
+
+
+ {view === "graph" &&
}
+
+ {view === "graph" ? : }
+
+ {view === "graph" &&
}
+
+
+ );
+}
diff --git a/src/components/campaigns/CampaignList.tsx b/src/components/campaigns/CampaignList.tsx
new file mode 100644
index 0000000..700ec53
--- /dev/null
+++ b/src/components/campaigns/CampaignList.tsx
@@ -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
([]);
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Campaigns
+
+ Design and schedule multi-platform campaign flows
+
+
+
+
+
+ {campaigns.length === 0 ? (
+
+
+
+
+
No campaigns yet
+
+ Create your first campaign to design visual workflows that schedule posts across multiple platforms.
+
+
+
+ ) : (
+
+ {campaigns.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/campaigns/EditorToolbar.tsx b/src/components/campaigns/EditorToolbar.tsx
new file mode 100644
index 0000000..0be8ba1
--- /dev/null
+++ b/src/components/campaigns/EditorToolbar.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+
setCampaignName(e.target.value)}
+ className="text-lg font-semibold bg-transparent text-foreground border-none outline-none max-w-[300px]"
+ />
+
+
+ {campaign.status}
+
+
+ {isSaving && (
+
+
+ Saving...
+
+ )}
+ {!isSaving && isDirty && (
+
Unsaved
+ )}
+ {!isSaving && !isDirty && (
+
Saved
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/campaigns/NodeDetailPanel.tsx b/src/components/campaigns/NodeDetailPanel.tsx
new file mode 100644
index 0000000..ed11565
--- /dev/null
+++ b/src/components/campaigns/NodeDetailPanel.tsx
@@ -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 (
+
+
+
updateNode(node.id, { label: e.target.value })}
+ className="text-sm font-semibold bg-transparent text-foreground border-none outline-none flex-1"
+ />
+
+
+
+
+
+
+
+ {node.type === "trigger" && (
+
+ )}
+ {node.type === "post" && (
+
+ )}
+ {node.type === "delay" && (
+
+ )}
+ {node.type === "condition" && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/campaigns/NodePalette.tsx b/src/components/campaigns/NodePalette.tsx
new file mode 100644
index 0000000..58b2da8
--- /dev/null
+++ b/src/components/campaigns/NodePalette.tsx
@@ -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: ,
+ color: "#22c55e",
+ description: "Start the campaign flow",
+ },
+ {
+ type: "post",
+ label: "Post",
+ icon: ,
+ color: "oklch(0.6 0.2 30)",
+ description: "Publish to platforms",
+ },
+ {
+ type: "delay",
+ label: "Delay",
+ icon: ,
+ color: "#f59e0b",
+ description: "Wait before next step",
+ },
+ {
+ type: "condition",
+ label: "Condition",
+ icon: ,
+ 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 (
+
+
+
Nodes
+
Drag onto canvas
+
+
+ {NODE_TYPES.map((nt) => (
+
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"
+ >
+
+
+ {nt.icon}
+
+
+
{nt.label}
+
{nt.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/campaigns/details/ConditionForm.tsx b/src/components/campaigns/details/ConditionForm.tsx
new file mode 100644
index 0000000..0c5e584
--- /dev/null
+++ b/src/components/campaigns/details/ConditionForm.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+ If {data.metric} {data.operator} {data.threshold} → true path, otherwise → false path
+
+
+ );
+}
diff --git a/src/components/campaigns/details/DelayForm.tsx b/src/components/campaigns/details/DelayForm.tsx
new file mode 100644
index 0000000..fd52c7e
--- /dev/null
+++ b/src/components/campaigns/details/DelayForm.tsx
@@ -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 (
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/campaigns/details/PostForm.tsx b/src/components/campaigns/details/PostForm.tsx
new file mode 100644
index 0000000..e7fea80
--- /dev/null
+++ b/src/components/campaigns/details/PostForm.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {data.platforms.map((pp) => {
+ const info = PLATFORMS.find((p) => p.id === pp.platform);
+ return (
+
+
+
+ {info?.label}
+
+
+
+
+ );
+ })}
+
+ {availablePlatforms.length > 0 && (
+
+ {availablePlatforms.map((p) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/campaigns/details/TriggerForm.tsx b/src/components/campaigns/details/TriggerForm.tsx
new file mode 100644
index 0000000..c15f4e6
--- /dev/null
+++ b/src/components/campaigns/details/TriggerForm.tsx
@@ -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 (
+
+
+
+
+
+
+ {data.triggerKind === "scheduled" && (
+
+
+
+ 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"
+ />
+
+ )}
+
+ {data.triggerKind === "cron" && (
+
+
+
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"
+ />
+
e.g. 0 9 * * 1-5 = weekdays at 9am
+
+ )}
+
+ );
+}
diff --git a/src/components/campaigns/graph/CampaignGraph.tsx b/src/components/campaigns/graph/CampaignGraph.tsx
new file mode 100644
index 0000000..d142205
--- /dev/null
+++ b/src/components/campaigns/graph/CampaignGraph.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/campaigns/graph/edges/CampaignEdge.tsx b/src/components/campaigns/graph/edges/CampaignEdge.tsx
new file mode 100644
index 0000000..e7bdada
--- /dev/null
+++ b/src/components/campaigns/graph/edges/CampaignEdge.tsx
@@ -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 (
+ <>
+
+ {/* Animated flow dot */}
+
+
+
+ {label && (
+
+ {String(label)}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/campaigns/graph/nodes/BaseNode.tsx b/src/components/campaigns/graph/nodes/BaseNode.tsx
new file mode 100644
index 0000000..82f2d65
--- /dev/null
+++ b/src/components/campaigns/graph/nodes/BaseNode.tsx
@@ -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 (
+
+ {hasInput && (
+
+ )}
+
+
+
+ {children && (
+
{children}
+ )}
+
+
+ {hasOutput && !hasTrueOutput && (
+
+ )}
+
+ {hasTrueOutput && (
+ <>
+
+
+ true
+
+ >
+ )}
+
+ {hasFalseOutput && (
+ <>
+
+
+ false
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/campaigns/graph/nodes/ConditionNode.tsx b/src/components/campaigns/graph/nodes/ConditionNode.tsx
new file mode 100644
index 0000000..f248022
--- /dev/null
+++ b/src/components/campaigns/graph/nodes/ConditionNode.tsx
@@ -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 (
+ }
+ color="#a855f7"
+ hasOutput={false}
+ hasTrueOutput
+ hasFalseOutput
+ >
+ {data.nodeData.metric} {data.nodeData.operator} {data.nodeData.threshold}
+
+ );
+}
diff --git a/src/components/campaigns/graph/nodes/DelayNode.tsx b/src/components/campaigns/graph/nodes/DelayNode.tsx
new file mode 100644
index 0000000..d987489
--- /dev/null
+++ b/src/components/campaigns/graph/nodes/DelayNode.tsx
@@ -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 (
+ }
+ color="#f59e0b"
+ >
+ Wait {amount} {unit}
+
+ );
+}
diff --git a/src/components/campaigns/graph/nodes/PostNode.tsx b/src/components/campaigns/graph/nodes/PostNode.tsx
new file mode 100644
index 0000000..ba06ec0
--- /dev/null
+++ b/src/components/campaigns/graph/nodes/PostNode.tsx
@@ -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 (
+ }
+ color="oklch(0.6 0.2 30)"
+ >
+
+ {platformCount > 0 && (
+
{platformCount} platform{platformCount !== 1 ? "s" : ""}
+ )}
+
{contentPreview}
+
+
+ );
+}
diff --git a/src/components/campaigns/graph/nodes/TriggerNode.tsx b/src/components/campaigns/graph/nodes/TriggerNode.tsx
new file mode 100644
index 0000000..468a6b8
--- /dev/null
+++ b/src/components/campaigns/graph/nodes/TriggerNode.tsx
@@ -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 (
+ }
+ color="#22c55e"
+ hasInput={false}
+ >
+ {desc}
+
+ );
+}
diff --git a/src/components/campaigns/timeline/CampaignTimeline.tsx b/src/components/campaigns/timeline/CampaignTimeline.tsx
new file mode 100644
index 0000000..d408896
--- /dev/null
+++ b/src/components/campaigns/timeline/CampaignTimeline.tsx
@@ -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 = {
+ 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("day");
+ const scrollRef = useRef(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();
+ 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 (
+
+
+
No timeline data yet
+
Add a Trigger node connected to Post nodes in the Graph view
+
+
+ );
+ }
+
+ return (
+
+ {/* Zoom controls */}
+
+
+ {entries.length} post{entries.length !== 1 ? "s" : ""} across {platformLanes.size} platform{platformLanes.size !== 1 ? "s" : ""}
+
+
+
+
+ {config.label}
+
+
+
+
+
+ {/* Timeline body */}
+
+
+
+
+
+ {Array.from(platformLanes.entries()).map(([platform, platformEntries]) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/campaigns/timeline/TimelineEntry.tsx b/src/components/campaigns/timeline/TimelineEntry.tsx
new file mode 100644
index 0000000..5dca9b4
--- /dev/null
+++ b/src/components/campaigns/timeline/TimelineEntry.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/campaigns/timeline/TimelineHeader.tsx b/src/components/campaigns/timeline/TimelineHeader.tsx
new file mode 100644
index 0000000..3ccf765
--- /dev/null
+++ b/src/components/campaigns/timeline/TimelineHeader.tsx
@@ -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 (
+
+ {slots.map((slot, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/campaigns/timeline/TimelineLane.tsx b/src/components/campaigns/timeline/TimelineLane.tsx
new file mode 100644
index 0000000..61fa714
--- /dev/null
+++ b/src/components/campaigns/timeline/TimelineLane.tsx
@@ -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 (
+
+
+
+
{info?.label || platform}
+
+
+ {entries.map((entry) => {
+ const left = offsetLeft + (entry.startTime.getTime() - startTime.getTime()) / msPerPixel;
+ const width = (entry.endTime.getTime() - entry.startTime.getTime()) / msPerPixel;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/campaigns/timeline/TimelineNowLine.tsx b/src/components/campaigns/timeline/TimelineNowLine.tsx
new file mode 100644
index 0000000..772f2ee
--- /dev/null
+++ b/src/components/campaigns/timeline/TimelineNowLine.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/campaign-storage.ts b/src/lib/campaign-storage.ts
new file mode 100644
index 0000000..cac0641
--- /dev/null
+++ b/src/lib/campaign-storage.ts
@@ -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 {
+ try {
+ await fs.access(dir);
+ } catch {
+ await fs.mkdir(dir, { recursive: true });
+ }
+}
+
+export async function saveCampaign(campaign: Campaign): Promise {
+ 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 {
+ 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 {
+ 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 {
+ const campaignDir = path.join(CAMPAIGNS_DIR, id);
+ try {
+ await fs.rm(campaignDir, { recursive: true });
+ } catch {
+ // Ignore if doesn't exist
+ }
+}
diff --git a/src/lib/stores/campaign-store.ts b/src/lib/stores/campaign-store.ts
new file mode 100644
index 0000000..36ce20d
--- /dev/null
+++ b/src/lib/stores/campaign-store.ts
@@ -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) => void;
+ updateNodeData: (id: string, data: Record) => void;
+ removeNode: (id: string) => void;
+ updateNodePosition: (id: string, position: { x: number; y: number }) => void;
+ selectNode: (id: string | null) => void;
+
+ // Edge ops
+ addEdge: (edge: Omit) => void;
+ removeEdge: (id: string) => void;
+
+ // View
+ setView: (view: EditorView) => void;
+
+ // Persistence
+ save: () => Promise;
+
+ // Timeline
+ getTimelineEntries: () => TimelineEntry[];
+}
+
+let saveTimeout: ReturnType | null = null;
+
+function debouncedSave(store: CampaignStore) {
+ if (saveTimeout) clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(() => {
+ store.save();
+ }, 3000);
+}
+
+function hasCycle(
+ edges: CampaignEdge[],
+ newEdge: Omit
+): boolean {
+ const adj = new Map();
+ 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();
+ const stack = new Set();
+
+ 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((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();
+ 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();
+ 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;
+ },
+}));
diff --git a/src/lib/types/campaign.ts b/src/lib/types/campaign.ts
new file mode 100644
index 0000000..2649aca
--- /dev/null
+++ b/src/lib/types/campaign.ts
@@ -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 };
+}