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:
parent
6898fee809
commit
3e7e57dd79
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
}));
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue