From 3e7e57dd796cc1dcb9c4dd2616e55145b1bcfa4d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 26 Feb 2026 06:42:43 +0000 Subject: [PATCH] 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 --- Dockerfile | 2 +- package-lock.json | 259 +++++++++++++- package.json | 4 +- src/app/api/campaigns/[id]/route.ts | 47 +++ src/app/api/campaigns/route.ts | 26 ++ src/app/campaigns/[id]/layout.tsx | 11 + src/app/campaigns/[id]/page.tsx | 18 + src/app/campaigns/layout.tsx | 7 + src/app/campaigns/page.tsx | 10 + src/components/Navbar.tsx | 6 + src/components/campaigns/CampaignCard.tsx | 87 +++++ src/components/campaigns/CampaignEditor.tsx | 45 +++ src/components/campaigns/CampaignList.tsx | 93 +++++ src/components/campaigns/EditorToolbar.tsx | 96 +++++ src/components/campaigns/NodeDetailPanel.tsx | 72 ++++ src/components/campaigns/NodePalette.tsx | 80 +++++ .../campaigns/details/ConditionForm.tsx | 81 +++++ .../campaigns/details/DelayForm.tsx | 47 +++ src/components/campaigns/details/PostForm.tsx | 101 ++++++ .../campaigns/details/TriggerForm.tsx | 58 +++ .../campaigns/graph/CampaignGraph.tsx | 179 ++++++++++ .../campaigns/graph/edges/CampaignEdge.tsx | 56 +++ .../campaigns/graph/nodes/BaseNode.tsx | 107 ++++++ .../campaigns/graph/nodes/ConditionNode.tsx | 27 ++ .../campaigns/graph/nodes/DelayNode.tsx | 25 ++ .../campaigns/graph/nodes/PostNode.tsx | 34 ++ .../campaigns/graph/nodes/TriggerNode.tsx | 34 ++ .../campaigns/timeline/CampaignTimeline.tsx | 146 ++++++++ .../campaigns/timeline/TimelineEntry.tsx | 51 +++ .../campaigns/timeline/TimelineHeader.tsx | 37 ++ .../campaigns/timeline/TimelineLane.tsx | 46 +++ .../campaigns/timeline/TimelineNowLine.tsx | 33 ++ src/lib/campaign-storage.ts | 61 ++++ src/lib/stores/campaign-store.ts | 330 ++++++++++++++++++ src/lib/types/campaign.ts | 150 ++++++++ 35 files changed, 2460 insertions(+), 6 deletions(-) create mode 100644 src/app/api/campaigns/[id]/route.ts create mode 100644 src/app/api/campaigns/route.ts create mode 100644 src/app/campaigns/[id]/layout.tsx create mode 100644 src/app/campaigns/[id]/page.tsx create mode 100644 src/app/campaigns/layout.tsx create mode 100644 src/app/campaigns/page.tsx create mode 100644 src/components/campaigns/CampaignCard.tsx create mode 100644 src/components/campaigns/CampaignEditor.tsx create mode 100644 src/components/campaigns/CampaignList.tsx create mode 100644 src/components/campaigns/EditorToolbar.tsx create mode 100644 src/components/campaigns/NodeDetailPanel.tsx create mode 100644 src/components/campaigns/NodePalette.tsx create mode 100644 src/components/campaigns/details/ConditionForm.tsx create mode 100644 src/components/campaigns/details/DelayForm.tsx create mode 100644 src/components/campaigns/details/PostForm.tsx create mode 100644 src/components/campaigns/details/TriggerForm.tsx create mode 100644 src/components/campaigns/graph/CampaignGraph.tsx create mode 100644 src/components/campaigns/graph/edges/CampaignEdge.tsx create mode 100644 src/components/campaigns/graph/nodes/BaseNode.tsx create mode 100644 src/components/campaigns/graph/nodes/ConditionNode.tsx create mode 100644 src/components/campaigns/graph/nodes/DelayNode.tsx create mode 100644 src/components/campaigns/graph/nodes/PostNode.tsx create mode 100644 src/components/campaigns/graph/nodes/TriggerNode.tsx create mode 100644 src/components/campaigns/timeline/CampaignTimeline.tsx create mode 100644 src/components/campaigns/timeline/TimelineEntry.tsx create mode 100644 src/components/campaigns/timeline/TimelineHeader.tsx create mode 100644 src/components/campaigns/timeline/TimelineLane.tsx create mode 100644 src/components/campaigns/timeline/TimelineNowLine.tsx create mode 100644 src/lib/campaign-storage.ts create mode 100644 src/lib/stores/campaign-store.ts create mode 100644 src/lib/types/campaign.ts 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 ( +
+
+ +