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
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
|
||||||
# Create data directory for zine storage
|
# 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
|
# Set ownership and make entrypoint executable
|
||||||
RUN chmod +x /app/entrypoint.sh && chown -R nextjs:nodejs /app
|
RUN chmod +x /app/entrypoint.sh && chown -R nextjs:nodejs /app
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
|
@ -24,7 +25,8 @@
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"viem": "^2.46.3",
|
"viem": "^2.46.3",
|
||||||
"wagmi": "^3.5.0",
|
"wagmi": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
@ -3190,6 +3192,55 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"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": {
|
"node_modules/abitype": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
|
||||||
|
|
@ -4328,6 +4468,12 @@
|
||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
|
|
@ -4399,6 +4545,111 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
@ -8825,9 +9076,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||||
"integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
|
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
|
@ -25,7 +26,8 @@
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"viem": "^2.46.3",
|
"viem": "^2.46.3",
|
||||||
"wagmi": "^3.5.0",
|
"wagmi": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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
|
rZine
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/campaigns"
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Campaigns
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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