diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..350b13a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install frontend deps + working-directory: ./client + run: npm install + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install backend deps + working-directory: ./server + run: composer install --no-interaction diff --git a/README.md b/README.md index 3ac345d..8ee02f2 100644 --- a/README.md +++ b/README.md @@ -1 +1,121 @@ -FLOWPILOT - YOUR N8N GENERATOR \ No newline at end of file +

FlowPilot

+ +

+ Copilot Logo +

+ +

+ + Flowpilot is a one of a kind n8n workflow generator.Generation of production ready, deployable and testable workflows is what Flowpilot strives in. + +

+ +

Architecture

+ + + + + + +
+ React Logo +

Frontend

+

React

+
+ Laravel Logo +

Backend

+

Laravel

+
+

Communication Flow

+ +

+ FlowPilot Communication Flow Diagram +

+ +

+ + High-level data flow between the frontend, backend and database. + +

+ +

+ FlowPilot Communication Flow Diagram +

+ +

+ + n8n Generation workflow between the frontend, backend, generation module, LLM, vector-db and an open SSE connection. + +

+ + +

System Design

+ + +

Sequence Diagram

+ + + + + + + + + +
+ React Logo +

Frontend

+

React

+
+ Laravel Logo +

Backend

+

Laravel

+
+ React Logo +

Frontend

+

React

+
+ Laravel Logo +

Backend

+

Laravel

+
+

ER Diagram

+

+ FlowPilot Communication Flow Diagram +

\ No newline at end of file diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..e859392 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,15 @@ +# The url to the current server version +VITE_BASE_URL=http://******** + +# Used for "continue with google" -> login/signing up using a google account +# handles user authentication for us +# create a google cloud account and then create a client with the url being your frontend url (js) +VITE_GOOGLE_CLIENT_ID=*******************.apps.googleusercontent.com + +# Same as base url but is compatable with the way we save file paths in DB +# Its still pointing to the server but change it according to how you save your paths +VITE_PHOTO_BASE_URL=http://********* + +# Specifies how strong user's password must be to be able to signup +# using zod the scale is set from 1 - 4 where 1 is the weaked and 4 the strongest +VITE_PASSWORD_THRESHOLD=** diff --git a/client/.gitignore b/client/.gitignore index 5c2d8e0..9f9f794 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -7,7 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -node_modules +node_modules/ dist dist-ssr *.local diff --git a/client/DockerFile b/client/DockerFile new file mode 100644 index 0000000..0f43457 --- /dev/null +++ b/client/DockerFile @@ -0,0 +1,13 @@ +FROM node:20-alpine as build + +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/client/index.html b/client/index.html index a16cb6b..1409639 100644 --- a/client/index.html +++ b/client/index.html @@ -2,12 +2,13 @@ - + - client + FlowPilot
+ diff --git a/client/nginx.conf b/client/nginx.conf new file mode 100644 index 0000000..5088a6e --- /dev/null +++ b/client/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + + # React static files + root /usr/share/nginx/html; + index index.html; + + # Serve static files normally + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy to Laravel + location /api/ { + proxy_pass http://backend_nginx:80; + } +} diff --git a/client/package-lock.json b/client/package-lock.json index 1a376db..19b1122 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,19 +8,26 @@ "name": "client", "version": "0.0.0", "dependencies": { - "@tanstack/react-query": "^5.90.16", + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.90.19", "axios": "^1.13.2", "fs": "^0.0.1-security", + "lucide-react": "^0.562.0", "path": "^0.12.7", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.11.0" + "react-hook-form": "^7.71.1", + "react-icons": "^5.5.0", + "react-router-dom": "^7.11.0", + "zod": "^4.3.5", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.10.4", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/zxcvbn": "^4.4.5", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -913,6 +920,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1330,10 +1349,16 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz", + "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==", "license": "MIT", "funding": { "type": "github", @@ -1341,12 +1366,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", + "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.16" + "@tanstack/query-core": "5.90.19" }, "funding": { "type": "github", @@ -1447,6 +1472,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/zxcvbn": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz", + "integrity": "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -2878,6 +2910,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3162,6 +3203,32 @@ "react": "^19.2.3" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3605,10 +3672,9 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", - "dev": true, + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "peer": true, "funding": { @@ -3627,6 +3693,12 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/client/package.json b/client/package.json index cd36b86..a09ea8b 100644 --- a/client/package.json +++ b/client/package.json @@ -10,19 +10,26 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.90.16", + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.90.19", "axios": "^1.13.2", "fs": "^0.0.1-security", + "lucide-react": "^0.562.0", "path": "^0.12.7", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.11.0" + "react-hook-form": "^7.71.1", + "react-icons": "^5.5.0", + "react-router-dom": "^7.11.0", + "zod": "^4.3.5", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.10.4", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/zxcvbn": "^4.4.5", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/client/public/vite.svg b/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 39897e0..115aab1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,42 +2,76 @@ import './App.css' import { BrowserRouter, Routes, Route } from 'react-router-dom' import Landing from './Pages/Landing' import { Copilot } from './Pages/copilot/Copilot' +import CommunityPage from './Pages/community/Community' +import Login from './Pages/Login/Login' +import Signup from './Pages/Signup/Signup' +import ProtectedRoutes from './Pages/components/ProtectedRoutes' +import ProfilePage from './Pages/profile/Profile' +import AboutPage from './Pages/AboutUs' +import SettingsPage from './Pages/Settings/Settings' -/** - * TIME CALENDER: - * COPILOT PAGE REQUIRED 8 MORE DAYS MAX TO FINISH ALL FEATURES -> INLUDES HUGE ARCHITECTURAL CAHNGES IN THE BACKEDN - * - * POSTS & PROFILE PAGES NEED 4 DAYS , 5 DAYS MAXIMUM - * - * ENHANCING N8N AI GENERATION NEEDS 5 DAYS OF WORK - * - * CUSTOM NODE GENERATION (IF TIME ALLOWS IT) -> 4 DAYS - * - * WITH NO ADD ONS WE HAVE : 17 DAYS - * - * WITH ADD ONS : 21 DAYS - * - * AT THE TIME I'M WRITING THIS I'M LEFT WITH 19 DAYS - */ +// day 22 I am fucked beyond comprehension +// Test tracing and generation +// Add the abiilty to send a workflow +// Clean the copilot frontend / backend files +// merge to main +// fix CV +// For after 12 am +// Deploy the website +// Create CI / CD +// push to main +// victory dance +// big objectives must start at 24th to give myself a chance of finishing them // ADD THE ABILITY TO SEND USER WORKFLOWS TO ADD ON IT/FIX IT - HARD - BACKEND HEAVY -// ENHANCE THE ABILITY TO CONTINUE THE CONVERSATION - HARD - BACKEND HEAVY -// HISOTRIES OVER 2 WEEKS OLD MUST BE AUTOMATICALLY DELETED - MEDIUM - BEACKEND HEAVY -// ADD PROMPT SAFEGURAD STAGE FOR VISCIOUS PROMPTS (forget everything, delete db exct/) - MEDIUM - BACKEND HEAVY - -// FIGURE OUT A BETTER WAY TO GET USER FEEDBACK CURRENTLY NOT VERY EFFICIENT NOR DOES IT MAKE SENSE - UNKNOWN - BAVKEND HEAVY - // ADD THE ABILITY TO CREATE CUSTOM NODES - VERY HARD - F/B HEAVY ON BOTH // ADD THE ABILITY TO SAVE CREDENTIALS OR FIGURE OUT A WAY TO DO IT AUTOMATICALLY - HARD F/B HEAVY ON BOTH -// Fix Retry taking so long to kick start again. Add Edit Message ability -// APPROX TIME : 8 DAYS TO FINISH (EXCLUDING ENHANCING THE AI'S ABILITY TO GENERATE WORKFLOWS) +// USE LANGCHAIN + +// white screen appearing on reload then page appears + +// objectives for today: +/** + * Use hook to send copilot requests + * Authenticate all requests + * Clean copilot backend + * Clean copilot frontend + * Create Tests for everything except copilot + * Create CI CD + * Deploy + * Tommorrow we need to start langchain giving us 3/4 days headstart so today everything must be perfectly done and dusted + */ + function App() { return ( } /> - } /> + } /> + } /> + + + + + }/> + + + + }/> + + + + }/> + + + + }/> + } /> ) diff --git a/client/src/Pages/AboutUs.tsx b/client/src/Pages/AboutUs.tsx index e69de29..15614c9 100644 --- a/client/src/Pages/AboutUs.tsx +++ b/client/src/Pages/AboutUs.tsx @@ -0,0 +1,104 @@ +import Header from "./components/Header"; +import "../styles/AboutUs.css"; +import logo from "../assets/Logo.png"; + +export default function AboutPage() { + return ( +
+
+ +
+
+
+

About FlowPilot

+

We turn intent into automation.

+
+ +
+ FlowPilot logo +
+
+
+ +
+
+

From ideas to workflows

+

+ FlowPilot removes the friction between what you want to automate and a + production-ready n8n workflow. Describe the outcome, not the wiring. + The system plans, reasons, and builds the workflow for you. +

+
+
+ +
+
+

Transparent by design

+

+ FlowPilot does not hide behind a black box. As workflows are generated, + you can see streaming traces that expose how the AI reasons about + triggers, nodes, and data flow. Every decision is inspectable. +

+

+ This makes automation easier to trust, easier to debug, and easier to + learn from. +

+
+
+ +
+
+

Built specifically for n8n

+

+ FlowPilot is not a generic automation generator. It is deeply aligned + with how n8n works, producing clean, import-ready workflows that follow + best practices and remain fully customizable. +

+
    +
  • Native n8n workflow structure
  • +
  • Readable, maintainable logic
  • +
  • Scales from simple flows to complex systems
  • +
+
+
+ +
+
+

Human-first interaction

+

+ You do not need to think in nodes or expressions. FlowPilot lets you + communicate intent in plain language while keeping you in full control + of the final workflow. +

+
+
+ +
+
+

A community of builders

+

+ FlowPilot is also a home for n8n builders. The community shares + workflows, patterns, and expert insight. Profiles reflect real + contributions, and reputation is built on usefulness. +

+
+
+ +
+
+

Our philosophy

+

Automation should be understandable.

+

AI should be collaborative, not opaque.

+

Great tools grow stronger with community.

+
+
+ +
+
+

FlowPilot

+

AI Powered n8n Automation

+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/Pages/Landing.tsx b/client/src/Pages/Landing.tsx index b20e60b..2f84833 100644 --- a/client/src/Pages/Landing.tsx +++ b/client/src/Pages/Landing.tsx @@ -2,8 +2,14 @@ import "../styles/Landing.css"; import Header from "./components/Header"; import n8nMediaImage from "../assets/n8n-media.png"; import FakeWorkflow from "./components/FakeWorkflow"; +import { Link, useNavigate } from "react-router-dom"; +import { useContext } from "react"; +import { AuthContext } from "../context/AuthContext"; -export default function LandingPage() { +export default function LandingPage(){ + + const authUser = useContext(AuthContext); + const navigate = useNavigate(); return (
@@ -13,7 +19,11 @@ export default function LandingPage() {

AI Generated Workflows

You Think It. We Make It.

- + {authUser ? ( + + ) : ( + + )}
@@ -26,7 +36,8 @@ export default function LandingPage() {
-

Chat With Your Own Data

+

Chat With Your Own Data

+

Create your own data chatter

@@ -98,9 +109,9 @@ export default function LandingPage() {

AI Powered n8n Automation

-

Community

-

Docs

-

Contact

+ Community + About us + Copilot
diff --git a/client/src/Pages/Login/Login.tsx b/client/src/Pages/Login/Login.tsx new file mode 100644 index 0000000..234c7d0 --- /dev/null +++ b/client/src/Pages/Login/Login.tsx @@ -0,0 +1,86 @@ +import "./login.css"; +import Header from "../components/Header"; +import { Link } from "react-router-dom"; +import WorkflowMarquee from "./components/WorkflowMarquee"; +import { useLogin } from "./hook/useLogin"; +import { useForm } from "react-hook-form"; +import { loginSchema, type LoginFormValues } from "./validation/login.schems"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const Login: React.FC = () => { + + const { handleLogin, loading } = useLogin(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + return ( +
+
+
+
+
+

Welcome back

+

+ Login to access your workflows and copilot. +

+
+ +
+ or +
+ +
+ + + + + +
+ +

+ Don't have an account? Create one +

+
+
+
+ +
+
+
+ ); +}; + +export default Login; diff --git a/client/src/Pages/Login/components/WorkflowMarquee.tsx b/client/src/Pages/Login/components/WorkflowMarquee.tsx new file mode 100644 index 0000000..8562bb2 --- /dev/null +++ b/client/src/Pages/Login/components/WorkflowMarquee.tsx @@ -0,0 +1,47 @@ +import wf3 from "../../../assets/workflows/wf3.webp"; +import wf6 from "../../../assets/workflows/highly_complicated_wf.webp"; +import wf7 from "../../../assets/workflows/production-ready-wf.avif"; + +const images = [ + { + image : wf6, + title : "Complicated Workflows" + }, + { + image : wf7, + title : "Production Ready Wrokflows" + }, + { + image: wf3, + title: "Huge Workflow" + } +] + +const WorkflowMarquee = () => { + return ( +
+
+ Start Generating +
+ +
+
+ {[...images, ...images].map((ob, index) => ( +
+
+
{ob.title}
+ workflow preview +
+
+ ))} +
+
+
+ ); +}; + +export default WorkflowMarquee; diff --git a/client/src/Pages/Login/hook/useLogin.ts b/client/src/Pages/Login/hook/useLogin.ts new file mode 100644 index 0000000..0db0496 --- /dev/null +++ b/client/src/Pages/Login/hook/useLogin.ts @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { getToken } from "../../../api/auth"; +import { useLoginMutation } from "./useLoginMutation"; +import { useLoginGoogleMutation } from "./useLoginGoogleMutation"; + +declare global { + interface Window { + google: any; + } +} + + +export function useLogin() { + const navigate = useNavigate(); + + const loginMutation = useLoginMutation(); + const googleMutation = useLoginGoogleMutation(); + + useEffect(() => { + const token = getToken(); + if (token) navigate("/"); + }, [navigate]); + + useEffect(() => { + let interval: number; + + const initGoogle = () => { + if (!window.google) return; + + window.google.accounts.id.initialize({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + callback: handleGoogleLogin, + auto_select: false, + }); + + const btn = document.getElementById("google-login-btn"); + if (btn) { + window.google.accounts.id.renderButton(btn, { + theme: "outline", + size: "large", + text: "continue_with", + shape: "rectangular", + width: 400, + }); + } + + window.google.accounts.id.disableAutoSelect(); + clearInterval(interval); + }; + + interval = window.setInterval(initGoogle, 100); + return () => clearInterval(interval); + }, []); + + const handleLogin = (data: { email: string; password: string }) => { + loginMutation.mutate(data); + }; + + const handleGoogleLogin = (response: any) => { + googleMutation.mutate(response); + }; + + return { + loading: loginMutation.isPending || googleMutation.isPending, + error: loginMutation.error || googleMutation.error, + handleLogin, + }; +} diff --git a/client/src/Pages/Login/hook/useLoginGoogleMutation.ts b/client/src/Pages/Login/hook/useLoginGoogleMutation.ts new file mode 100644 index 0000000..9287bb3 --- /dev/null +++ b/client/src/Pages/Login/hook/useLoginGoogleMutation.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; +import { setToken } from "../../../api/auth"; +import { api } from "../../../api/client"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; + +export const useLoginGoogleMutation = () => { + return useMutation({ + mutationFn: (payload: any) => googleLogin(payload), + onSuccess: (data) => { + setToken(data.token); + window.location.href = "/"; + }, + }); +}; + + +async function googleLogin(response : any){ + const res = await api.post("google" , {idToken: response.credential}); + return returnDataFormat(res); +} \ No newline at end of file diff --git a/client/src/Pages/Login/hook/useLoginMutation.ts b/client/src/Pages/Login/hook/useLoginMutation.ts new file mode 100644 index 0000000..dbbf3c7 --- /dev/null +++ b/client/src/Pages/Login/hook/useLoginMutation.ts @@ -0,0 +1,22 @@ +import { useMutation } from "@tanstack/react-query"; +import { setToken } from "../../../api/auth"; +import { api } from "../../../api/client"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; +import type { AuthResponse } from "../../types"; + +export const useLoginMutation = () => { + return useMutation({ + mutationFn: (payload: any) => login(payload), + onSuccess: (data) => { + setToken(data.token); + window.location.href = "/"; + }, + }); +}; + + +async function login({email , password} : { password : string , email : string}){ + const res = await api.post("login" , { email , password}); + return returnDataFormat(res); +} + diff --git a/client/src/Pages/Login/login.css b/client/src/Pages/Login/login.css new file mode 100644 index 0000000..fb52036 --- /dev/null +++ b/client/src/Pages/Login/login.css @@ -0,0 +1,294 @@ +.auth-page { + min-height: 100vh; + background: linear-gradient( + to bottom, + #050300 0%, + #271300 40%, + #050300 100% + ); + color: #ffffff; + overflow: hidden; +} + +.auth-layout { + display: grid; + grid-template-columns: 1fr 1fr; + height: calc(100vh - 80px); +} +.auth-left { + padding-top: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-right { + position: relative; + overflow: hidden; + background: transparent; +} + +.auth-card { + height: 97%; + width: 100%; + max-width: 480px; + padding: 24px 32px 32px; + border-radius: 18px; + border: 1px solid rgba(255, 155, 0, 0.2); + box-shadow: 0 12px 40px rgba(255, 155, 0, 0.15); +} + +.auth-card h1 { + font-size: 28px; + margin-bottom: 8px; +} + +.auth-subtitle { + opacity: 0.85; + margin-bottom: 24px; +} + +.auth-error { + margin: 14px 0; + padding: 12px 14px; + border-radius: 8px; + + background: rgba(220, 38, 38, 0.08); + border: 1px solid rgba(220, 38, 38, 0.25); + + color: #b91c1c; + font-size: 14px; + line-height: 1.4; + + display: flex; + align-items: center; + gap: 10px; +} + +.auth-error::before { + content: "⚠"; + font-size: 16px; + line-height: 1; +} + +@media (prefers-color-scheme: dark) { + .auth-error { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.35); + color: #fca5a5; + } +} + + +.auth-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.auth-label { + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-input { + padding: 9px 12px; + border-radius: 10px; + background: rgba(5, 3, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.12); + color: white; +} +.auth-card h1 { + font-size: 26px; +} + +.auth-input:focus { + outline: none; + border-color: rgba(255, 155, 0, 0.9); + box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.18); +} + +.auth-button { + margin-top: 8px; + padding: 12px; + border-radius: 12px; + border: none; + background: linear-gradient(90deg, #ff9b00, #ffb84d); + font-weight: 600; + cursor: pointer; +} + +.auth-footer-text { + margin-top: 18px; + font-size: 14px; +} + +.auth-footer-text a { + color: #ffcf70; +} + +.workflow-marquee { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.workflow-header { + position: sticky; + top: 0; + z-index: 5; + height: 72px; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 20px; + font-weight: 600; + letter-spacing: 0.3px; + + background: linear-gradient( + to bottom, + rgba(5, 3, 0, 0.95), + rgba(5, 3, 0, 0.6), + rgba(5, 3, 0, 0) + ); + + border-bottom: 1px solid rgba(255, 155, 0, 0.25); + backdrop-filter: blur(6px); +} + +.workflow-track { + display: flex; + flex-direction: column; + margin-top: 24px; + animation: scroll-up 35s linear infinite; + will-change: transform; +} + +.workflow-item { + display: flex; + justify-content: center; +} + + +.workflow-image { + width: calc(100% - 96px); + max-width: 420px; + height: 200px; + margin: 22px 48px; + border-radius: 14px; + object-fit: cover; + transition: transform 300ms ease, box-shadow 300ms ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.workflow-item:hover .workflow-image { + transform: scale(1.035); + box-shadow: 0 18px 36px rgba(255, 155, 0, 0.25); +} + +.workflow-overlay { + position: absolute; + top: 32px; + left: 32px; + z-index: 3; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.overlay-card { + padding: 10px 14px; + font-size: 13px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 155, 0, 0.25); + backdrop-filter: blur(6px); +} + +/* ---------- ANIMATIONS ---------- */ +@keyframes scroll-up { + from { transform: translateY(0); } + to { transform: translateY(-50%); } +} + +@keyframes drift-left { + from { transform: translateX(0); } + to { transform: translateX(-6px); } +} + +@keyframes drift-right { + from { transform: translateX(0); } + to { transform: translateX(6px); } +} + + +.google-login-btn { + display: flex; + justify-content: center; + margin-bottom: 1rem; +} + +.auth-divider { + display: flex; + align-items: center; + margin: 1rem 0; +} + +.auth-divider span { + margin: 0 auto; + color: #999; + font-size: 0.9rem; +} + +.login-workflow-card { + margin-right: 100px; + + position: relative; + width: 100%; + max-width: 420px; +} + +.workflow-title { + position: absolute; + top: -20px; + left: 50px; + right: 12px; + z-index: 2; + width:100%; + + padding: 8px 12px; + border-radius: 10px; + + font-size: 14px; + font-weight: 600; + + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 155, 0, 0.3); + + color: #ffcf70; +} + +.workflow-image { + width: 100%; + height: 200px; + border-radius: 14px; + object-fit: cover; +} + +@media (max-width: 900px) { + .auth-layout { + grid-template-columns: 1fr; + } + + .auth-right { + display: none; + } +} diff --git a/client/src/Pages/Login/validation/login.schems.ts b/client/src/Pages/Login/validation/login.schems.ts new file mode 100644 index 0000000..200fb6c --- /dev/null +++ b/client/src/Pages/Login/validation/login.schems.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z.string().email("Invalid email"), + password: z.string().min(1, "Password is required"), +}); + +export type LoginFormValues = z.infer; diff --git a/client/src/Pages/Profile.tsx b/client/src/Pages/Profile.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/Pages/Settings/Settings.tsx b/client/src/Pages/Settings/Settings.tsx new file mode 100644 index 0000000..f71c4d9 --- /dev/null +++ b/client/src/Pages/Settings/Settings.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { + FiArrowLeft, + FiLock, + FiLink, + FiLogOut, + FiXCircle, +} from "react-icons/fi"; +import "./settings.css"; +import { useUserAccount } from "./hook/useFetchAccountType"; +import { useSetPassword } from "./hook/useSetPassword"; +import { useLinkN8nAccount } from "./hook/useLinkN8nAccount"; +import { useUnlinkGoogleAccount } from "./hook/useUnlinkGoogleAccount"; +import { useAuth } from "../../context/useAuth"; + +type Tab = "password" | "n8n" | "unlink-google" | "logout"; + +const SettingsPage = () => { + const [activeTab, setActiveTab] = useState("password"); + + const { data, isPending } = useUserAccount(); + const { logout } = useAuth(); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [n8nBaseUrl, setN8nBaseUrl] = useState(""); + const [n8nApiKey, setN8nApiKey] = useState(""); + + const setPasswordMutation = useSetPassword(); + const linkN8nMutation = useLinkN8nAccount(); + const unlinkGoogleMutation = useUnlinkGoogleAccount(); + + if (isPending) return null; + + const hasPassword = data?.normalAccount; + const hasGoogle = data?.googleAccount; + + + const handleSetPassword = () => { + if (newPassword !== confirmPassword) return; + + setPasswordMutation.mutate({ + current_password: hasPassword ? currentPassword : undefined, + new_password: newPassword, + new_password_confirmation: confirmPassword, + }); + }; + + const handleLinkN8n = () => { + if (!n8nBaseUrl || !n8nApiKey) return; + + linkN8nMutation.mutate({ + base_url: n8nBaseUrl, + api_key: n8nApiKey, + }); + }; + + const handleUnlinkGoogle = () => { + unlinkGoogleMutation.mutate(); + }; + + + return ( +
+ + +
+ {/* Sidebar */} + + + {/* Content */} +
+ {/* SET PASSWORD */} + {activeTab === "password" && ( +
+

Set Password

+ + {!hasPassword && ( +

+ You signed up using Google. Set a password to enable email + login. +

+ )} + +
+ {hasPassword && ( + setCurrentPassword(e.target.value)} + /> + )} + + setNewPassword(e.target.value)} + /> + + setConfirmPassword(e.target.value)} + /> + + +
+
+ )} + + {/* N8N */} + {activeTab === "n8n" && ( +
+

Link n8n Account

+

Connect your n8n instance.

+ +
+ setN8nBaseUrl(e.target.value)} + /> + setN8nApiKey(e.target.value)} + /> + + +
+
+ )} + + {/* UNLINK GOOGLE */} + {activeTab === "unlink-google" && ( +
+

Unlink Google Account

+

+ You will no longer be able to log in using Google. + {!hasPassword && " Make sure to set a password first."} +

+ + +
+ )} + + {/* LOGOUT */} + {activeTab === "logout" && ( +
+

Logout

+

You will be logged out from this device.

+ +
+ )} +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/client/src/Pages/Settings/hook/useFetchAccountType.ts b/client/src/Pages/Settings/hook/useFetchAccountType.ts new file mode 100644 index 0000000..b000111 --- /dev/null +++ b/client/src/Pages/Settings/hook/useFetchAccountType.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; +import { api } from "../../../api/client"; +import type { UserAccountType } from "../types"; + +export const useUserAccount = () => { + return useQuery({ + queryKey: ["user-account-type"], + queryFn: getUserAccount, + staleTime: 5 * 60 * 1000, + }); +}; + +const getUserAccount = async (): Promise => { + const resp = await api.get("auth/account"); + return returnDataFormat(resp); +}; diff --git a/client/src/Pages/Settings/hook/useLinkN8nAccount.ts b/client/src/Pages/Settings/hook/useLinkN8nAccount.ts new file mode 100644 index 0000000..7a7b465 --- /dev/null +++ b/client/src/Pages/Settings/hook/useLinkN8nAccount.ts @@ -0,0 +1,21 @@ +import { useMutation } from "@tanstack/react-query"; +import { ToastMessage } from "../../components/toast/toast.types"; +import { useToast } from "../../../context/toastContext"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; +import { api } from "../../../api/client"; +import type { N8nLinkPayload } from "../types"; + +export const useLinkN8nAccount = () => { + const { showToast } = useToast(); + + return useMutation({ + mutationFn: linkN8n, + onSuccess:() => showToast("Linked n8n account successfully" , ToastMessage.SUCCESS) + }); +}; + + +const linkN8n = async (payload: N8nLinkPayload ) => { + const resp = await api.post("auth/linkN8nAccount", payload); + return returnDataFormat(resp); +}; diff --git a/client/src/Pages/Settings/hook/useSetPassword.ts b/client/src/Pages/Settings/hook/useSetPassword.ts new file mode 100644 index 0000000..dac85cd --- /dev/null +++ b/client/src/Pages/Settings/hook/useSetPassword.ts @@ -0,0 +1,19 @@ +import { useMutation } from "@tanstack/react-query"; +import { ToastMessage } from "../../components/toast/toast.types"; +import { useToast } from "../../../context/toastContext"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; +import { api } from "../../../api/client"; +import type { SetPasswordPayload } from "../types"; + +export const useSetPassword = () => { + const { showToast } = useToast(); + return useMutation({ + mutationFn: setPassword, + onSuccess:() => showToast("Set new password successfully" , ToastMessage.SUCCESS) + }); +}; + +const setPassword = async (payload: SetPasswordPayload) => { + const resp = await api.post("auth/setPassword", payload); + return returnDataFormat(resp); +}; \ No newline at end of file diff --git a/client/src/Pages/Settings/hook/useUnlinkGoogleAccount.ts b/client/src/Pages/Settings/hook/useUnlinkGoogleAccount.ts new file mode 100644 index 0000000..5926f05 --- /dev/null +++ b/client/src/Pages/Settings/hook/useUnlinkGoogleAccount.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ToastMessage } from "../../components/toast/toast.types"; +import { useToast } from "../../../context/toastContext"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; +import { api } from "../../../api/client"; + +export const useUnlinkGoogleAccount = () => { + const qc = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: unlinkGoogle, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["user-account-type"] }); + qc.invalidateQueries({ queryKey: ["profile"] }); + showToast("Linked n8n account successfully" , ToastMessage.SUCCESS) + }, + + }); +}; + + +const unlinkGoogle = async () => { + const resp = await api.put("auth/unlinkGoogleAccount"); + return returnDataFormat(resp); +}; + diff --git a/client/src/Pages/Settings/settings.css b/client/src/Pages/Settings/settings.css new file mode 100644 index 0000000..0048d8b --- /dev/null +++ b/client/src/Pages/Settings/settings.css @@ -0,0 +1,148 @@ +.settings-page { + min-height: 100vh; + padding: 24px; + color: var(--text, #e6e6e6); +} + +.settings-back-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + color: #aaa; + cursor: pointer; + margin-bottom: 24px; + font-size: 14px; +} + +.settings-back-btn:hover { + color: #fff; +} + +.settings-layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; +} + +.settings-sidebar { + background: #000000; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-tab { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 10px; + background: transparent; + border: none; + color: #cfcfcf; + cursor: pointer; + text-align: left; + font-size: 14px; +} + +.settings-tab:hover { + background: #1e2230; +} + +.settings-tab.active { + background: #242a3b; + color: #fff; +} + +.settings-tab.logout { + margin-top: auto; + color: #ff6b6b; +} + +.settings-tab.logout:hover { + background: rgba(255, 107, 107, 0.1); +} + +.settings-content { + background: #000000; + border-radius: 12px; + padding: 24px; +} + +.settings-content h2 { + margin-bottom: 6px; +} + +.settings-content p { + color: #aaa; + margin-bottom: 20px; +} + +.settings-form { + max-width: 420px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.settings-form input { + background: #0f1115; + border: 1px solid #2a2f42; + border-radius: 8px; + padding: 10px 12px; + color: #fff; +} + +.settings-form input:focus { + outline: none; + border-color: #4c6fff; +} + +.primary-btn { + margin-top: 12px; + background: #fda400; + border: none; + border-radius: 8px; + padding: 10px; + color: #fff; + cursor: pointer; + font-weight: 500; +} + +.primary-btn:hover { + background: #fbbb4c; +} + +.danger-btn { + background: #ff4d4f; + border: none; + border-radius: 8px; + padding: 10px 16px; + color: #fff; + cursor: pointer; + font-weight: 500; +} + +.danger-btn:hover { + background: #e03a3c; +} + +/* Responsive */ +@media (max-width: 768px) { + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-sidebar { + flex-direction: row; + overflow-x: auto; + } + + .settings-tab { + white-space: nowrap; + } +} diff --git a/client/src/Pages/Settings/types.ts b/client/src/Pages/Settings/types.ts new file mode 100644 index 0000000..a2465af --- /dev/null +++ b/client/src/Pages/Settings/types.ts @@ -0,0 +1,15 @@ +export type UserAccountType = { + normalAccount: boolean; + googleAccount: boolean; +}; + +export type N8nLinkPayload = { + base_url: string; + api_key: string; +} + +export type SetPasswordPayload = { + current_password?: string; + new_password: string; + new_password_confirmation: string; +} \ No newline at end of file diff --git a/client/src/Pages/Signup/Signup.tsx b/client/src/Pages/Signup/Signup.tsx new file mode 100644 index 0000000..a83ff16 --- /dev/null +++ b/client/src/Pages/Signup/Signup.tsx @@ -0,0 +1,146 @@ +import Header from "../components/Header"; +import { useNavigate, Link } from "react-router-dom"; +import "./signup.css"; +import { useSignup } from "./hook/useSignup.hook"; +import { useForm } from "react-hook-form"; +import { signupSchema, type SignupFormValues } from "./validation/signup.schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { setToken } from "../../api/auth"; +import { useMemo } from "react"; +import zxcvbn from "zxcvbn"; +import { strengthColors, strengthLabels } from "./constants"; + +const Signup: React.FC = () =>{ + const navigate = useNavigate(); + const signup = useSignup(); + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(signupSchema), + }); + const password = watch("password", ""); + + const passwordStrength = useMemo(() => { + if (!password) return null; + return zxcvbn(password); + }, [password]); + + const onSubmit = async (data: SignupFormValues) =>{ + const resp = await signup.mutateAsync(data); + setToken(resp.token); + navigate("/"); + }; + + + return ( +
+
+ +
+
+

Create your account

+

+ Join the FlowPilot community and start automating. +

+ +
+
+ + + +
+ + + +
+
+
+ ); +}; + +export default Signup; diff --git a/client/src/Pages/Signup/constants.ts b/client/src/Pages/Signup/constants.ts new file mode 100644 index 0000000..6b6093e --- /dev/null +++ b/client/src/Pages/Signup/constants.ts @@ -0,0 +1,9 @@ +export const strengthLabels = ["Very weak", "Weak", "Fair", "Good", "Strong"]; + +export const strengthColors = [ + "#e74c3c", + "#e67e22", + "#f1c40f", + "#2ecc71", + "#27ae60", +]; \ No newline at end of file diff --git a/client/src/Pages/Signup/hook/useSignup.hook.ts b/client/src/Pages/Signup/hook/useSignup.hook.ts new file mode 100644 index 0000000..a591543 --- /dev/null +++ b/client/src/Pages/Signup/hook/useSignup.hook.ts @@ -0,0 +1,24 @@ +import { useMutation } from "@tanstack/react-query"; +import { useToast } from "../../../context/toastContext"; +import { ToastMessage } from "../../components/toast/toast.types"; +import { api } from "../../../api/client"; +import type { RegisterPayload } from "../types"; +import type { AuthResponse } from "../../types"; +import { returnDataFormat } from "../../utils/returnApiDataFormat"; + +export const useSignup = () =>{ + const { showToast } = useToast(); + return useMutation({ + mutationFn: (payload: RegisterPayload) => register(payload), + onSuccess : () =>{ + showToast("Signup successfull" , ToastMessage.SUCCESS); + } + }); +} + +const register = async (payload: RegisterPayload) : Promise => { + const res = await api.post("register" , payload); + return returnDataFormat(res); +} + + diff --git a/client/src/Pages/Signup/signup.css b/client/src/Pages/Signup/signup.css new file mode 100644 index 0000000..d0c52f3 --- /dev/null +++ b/client/src/Pages/Signup/signup.css @@ -0,0 +1,160 @@ +.signup-page { + min-height: 100vh; + background: linear-gradient( + to bottom, + #050300 0%, + #271300 40%, + #050300 100% + ); + color: #ffffff; + overflow: hidden; +} + +.signup-main { + height: calc(100vh - 80px); + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; + margin-top:50px; + margin-bottom:50px; +} + +.signup-card { + width: 100%; + max-width: 520px; + + padding: 28px 32px 32px; + border-radius: 18px; + + border: 1px solid rgba(255, 155, 0, 0.2); +} + +.signup-card h1 { + font-size: 26px; + margin-bottom: 8px; +} + +.signup-subtitle { + font-size: 14px; + opacity: 0.85; + margin-bottom: 20px; +} + +.signup-error { + margin: 14px 0; + padding: 12px 14px; + border-radius: 8px; + + background: rgba(220, 38, 38, 0.08); + border: 1px solid rgba(220, 38, 38, 0.25); + color: #b91c1c; + + font-size: 14px; + display: flex; + align-items: center; + gap: 10px; +} + +.signup-error::before { + content: "⚠"; + font-size: 16px; +} + +.signup-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.signup-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.signup-label { + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.signup-input { + padding: 9px 12px; + border-radius: 10px; + + background: rgba(5, 3, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.12); + color: white; +} + +.signup-input:focus { + outline: none; + border-color: rgba(255, 155, 0, 0.9); +} + +.signup-button { + margin-top: 10px; + padding: 12px; + border-radius: 12px; + + border: none; + cursor: pointer; + font-weight: 600; + + background: linear-gradient(90deg, #ff9b00, #ffb84d); +} + +.signup-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.signup-footer-text { + margin-top: 18px; + font-size: 14px; +} + +.signup-footer-text a { + color: #ffcf70; +} + +.password-strength { + margin-top: 6px; +} + +.strength-bar { + height: 6px; + width: 100%; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; +} + +.strength-bar-fill { + height: 100%; + transition: width 0.3s ease, background-color 0.3s ease; +} + +.strength-text { + display: block; + margin-top: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.field-error{ + color: red; +} + + +@media (max-width: 520px) { + .signup-row { + grid-template-columns: 1fr; + } + + .signup-card { + padding: 24px; + } +} diff --git a/client/src/Pages/Signup/types.ts b/client/src/Pages/Signup/types.ts new file mode 100644 index 0000000..fddbd67 --- /dev/null +++ b/client/src/Pages/Signup/types.ts @@ -0,0 +1,6 @@ +export interface RegisterPayload{ + firstName: string; + lastName: string; + email: string; + password: string; +} \ No newline at end of file diff --git a/client/src/Pages/Signup/validation/password.schema.ts b/client/src/Pages/Signup/validation/password.schema.ts new file mode 100644 index 0000000..a964d5b --- /dev/null +++ b/client/src/Pages/Signup/validation/password.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import zxcvbn from "zxcvbn"; + +const passwordWeaknessThreshold = import.meta.env.VITE_PASSWORD_THRESHOLD; + +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .refine( + (password) => zxcvbn(password).score >= passwordWeaknessThreshold, + "Password is too weak" + ); diff --git a/client/src/Pages/Signup/validation/signup.schema.ts b/client/src/Pages/Signup/validation/signup.schema.ts new file mode 100644 index 0000000..e868f83 --- /dev/null +++ b/client/src/Pages/Signup/validation/signup.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { passwordSchema } from "./password.schema"; + +export const signupSchema = z + .object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + email: z.string().email("Invalid email"), + password: passwordSchema, + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +export type SignupFormValues = z.infer; diff --git a/client/src/Pages/community/Community.tsx b/client/src/Pages/community/Community.tsx new file mode 100644 index 0000000..eb292c5 --- /dev/null +++ b/client/src/Pages/community/Community.tsx @@ -0,0 +1,110 @@ +import React, { useState } from "react"; +import "./community.css"; +import Header from "../components/Header"; +import { useFetchPosts } from "./hook/useFetchPosts"; +import { useInfiniteScroll } from "./hook/useInfiniteScroll"; +import PostCard from "./components/PostCard"; +import CreatePostModal from "./components/CreatePostModal"; +import { adaptCommunityPost } from "./adapters/CommunityPostAdapter"; +import { useAuth } from "../../context/useAuth"; +import { Spinner } from "../components/Spinner"; + +const CommunityPage: React.FC = () => { + const { user, loading } = useAuth(); + + const [showModal, setShowModal] = useState(false); + + const handleStartPost = () => setShowModal(true); + + if (loading) { + return ; + } + + const { + posts, + isPending, + isFetchingMore, + hasMore, + loadMore, + error, + } = useFetchPosts(); + + const loadMoreRef = useInfiniteScroll({ + hasMore, + isLoading: isFetchingMore, + onLoadMore: loadMore, + }); + + + return ( + <> +
+
+ +
+
+
+
+

n8n Community

+

+ Automation builders sharing tips, flows, and integrations for n8n. +

+
+
+ + {error &&
Error loading posts
} + {isPending && + Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {posts.map((post) => ( + + ))} + +
+ + {isFetchingMore &&
Loading more…
} +
+ + +
+ +
+ + setShowModal(false)} + /> + + ); +}; + +export default CommunityPage; diff --git a/client/src/Pages/community/adapters/CommunityPostAdapter.ts b/client/src/Pages/community/adapters/CommunityPostAdapter.ts new file mode 100644 index 0000000..0778e55 --- /dev/null +++ b/client/src/Pages/community/adapters/CommunityPostAdapter.ts @@ -0,0 +1,19 @@ +import type { PostCardData } from "../../profile/types"; +import type { PostDto } from "../types"; + +export const adaptCommunityPost = (post: PostDto): PostCardData => ({ + id: post.id, + title: post.title, + content: post.content, + photo: post.photo, + + author: post.author, + username: post.username, + avatar: post.avatar, + + likes: post.likes, + comments: post.comments, + imports: post.exports, + + liked_by_me: post.liked_by_me, +}); diff --git a/client/src/Pages/community/adapters/ProfilePostAdapter.ts b/client/src/Pages/community/adapters/ProfilePostAdapter.ts new file mode 100644 index 0000000..7cbb1dd --- /dev/null +++ b/client/src/Pages/community/adapters/ProfilePostAdapter.ts @@ -0,0 +1,13 @@ +import type { PostCardData } from "../../profile/types"; + +export const adaptListPost = (post: any): PostCardData => ({ + id: post.id, + + title: post.title, + content: post.description, + photo: post.photo_url, + + likes: post.likes, + comments: post.comments_count, + imports: post.imports, +}); \ No newline at end of file diff --git a/client/src/Pages/community/community.css b/client/src/Pages/community/community.css new file mode 100644 index 0000000..f33d9f9 --- /dev/null +++ b/client/src/Pages/community/community.css @@ -0,0 +1,748 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: system-ui, sans-serif; +} + +body { + background: radial-gradient(circle at top, #2b1208, #000); + color: #fff; +} + +.community-page { + min-height: 100vh; +} + +/* NAVBAR */ +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 40px; + border-bottom: 1px solid #222; +} + +.logo { + font-weight: 600; + color: #f5b642; +} + +.nav-links { + display: flex; + gap: 24px; +} + +.nav-links a { + cursor: pointer; + color: #ccc; + font-size: 14px; +} + +.nav-links .login { + color: #fff; +} + +/* FEED */ +.feed { + max-width: 720px; + width: 100%; +} + + +/* POST CARD */ +.post-card { + background: #0b0b0b; + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; + border: 1px solid #1f1f1f; +} + +.post-header { + display: flex; + align-items: center; + gap: 12px; +} + +.post-header img { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.post-author { + font-weight: 600; +} + +.post-username { + font-size: 12px; + color: #888; +} + +.post-imports { + margin-left: auto; + font-size: 12px; + color: #aaa; +} + +.post-content { + margin: 16px 0; + font-size: 15px; +} + +/* Preview placeholder */ +.post-preview { + height: 180px; + background: #141414; + border-radius: 8px; + margin-bottom: 16px; +} + +/* ACTIONS */ +.post-actions { + display: flex; + gap: 12px; +} + +.post-actions button { + flex: 1; + padding: 8px; + background: #121212; + border: 1px solid #222; + color: #fff; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.post-actions button:hover { + background: #1a1a1a; +} + +.post-stats { + margin-top: 8px; + font-size: 12px; + color: #888; +} + +.like-btn.liked { + background: #f5b642; + color: #000; + border-color: #f5b642; +} + +.like-btn { + transition: background 0.15s ease, color 0.15s ease; +} + +.like-btn.liked:hover { + background: #ffcb66; +} + + +/* MODAL */ +.comments-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.comments-modal { + width: 90%; + max-width: 1100px; + height: 80vh; + background: #0b0b0b; + border-radius: 14px; + display: flex; + border: 1px solid #1f1f1f; + overflow: hidden; +} + +/* LEFT */ +.comments-modal-post { + flex: 1; + padding: 24px; + overflow-y: auto; + border-right: 1px solid #1f1f1f; +} + +/* RIGHT */ +.comments-modal-comments { + flex: 1; + display: flex; + flex-direction: column; +} + +/* COMMENTS LIST */ +.comments-list { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.comment { + display: flex; + gap: 12px; + padding: 14px; + margin-bottom: 12px; + + background: #111; /* slightly lighter than modal bg */ + border: 1px solid #1f1f1f; + border-radius: 12px; +} + +.comment:last-child { + margin-bottom: 0; +} + +.comment-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.comment img { + width: 36px; + height: 36px; + border-radius: 50%; +} + +.comment-body { + flex: 1; +} + +.comment-author { + font-weight: 600; + font-size: 14px; +} + + + +.comment-content { + font-size: 14px; + margin: 4px 0; +} + +.comment-actions { + margin-top: 6px; +} + +.comment-like { + display: inline-flex; + align-items: center; + gap: 6px; + + background: none; + border: none; + cursor: pointer; + + color: #fff; + font-size: 12px; + font-weight: 500; + + padding: 4px 6px; + border-radius: 6px; + + transition: color 0.15s ease, background 0.15s ease; +} + +.comment-like:hover { + background: rgba(255, 255, 255, 0.06); +} + +.comment-like.liked { + color: #f5b642; /* match your theme */ +} + + +/* INPUT */ +.comment-input { + border-top: 1px solid #1f1f1f; + padding: 16px; + display: flex; + gap: 12px; +} + +.comment-input textarea { + flex: 1; + resize: none; + background: #121212; + border: 1px solid #222; + border-radius: 8px; + padding: 10px; + color: #fff; + font-size: 14px; + line-height: 1.4; + max-height: 160px; /* ~5 lines */ + overflow-y: auto; +} + +.comment-input button { + padding: 0 16px; + background: #f5b642; + color: #000; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + + +/* Feed header: title at left, CTA at right */ +.feed-header { + width: 100%; + padding: 0 16px; + margin-bottom: 20px; +} +.feed-header-inner { + width: 100%; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +/* Title and subtitle */ +.page-meta { + display: flex; + flex-direction: column; +} + +.page-title { + font-size: 24px; + font-weight: 700; + color: #f5b642; + margin-bottom: 6px; + line-height: 1.1; +} + +.page-subtitle { + font-size: 13px; + color: #cfcfcf; + opacity: 0.95; +} + +.start-post-btn { + appearance: none; + border: none; + background: linear-gradient(180deg, #f5b642, #e1a11f); + color: #000; + padding: 10px 14px; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 6px 18px rgba(0,0,0,0.45); + white-space: nowrap; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.start-post-btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 26px rgba(0,0,0,0.55); +} + +.community-layout { + display: flex; + align-items: flex-start; + gap: 32px; + padding: 40px 40px 40px 40px; +} + +.community-sidebar { + flex: 1; + min-width: 280px; + top: 100px; + position: sticky; +} + +.sticky-profile-card { + top: -20px; + position:sticky; + background: #0b0b0b; + max-width: 420px; + border: 1px solid #1f1f1f; + border-radius: 14px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.profile-header { + display: flex; + align-items: center; + gap: 12px; +} +.community-profile-avatar { + width: 60px; + height: 60px; + border-radius: 50%; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 20px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + + color: #111; + background: #f5b642; + + border: 1px solid #222; + box-shadow: none; + + object-fit: cover; + user-select: none; +} + + + +.profile-meta { + display: flex; + flex-direction: column; +} + +.profile-name { + font-weight: 600; + font-size: 15px; +} + +.profile-username { + font-size: 13px; + color: #888; +} + +.start-post-btn.full-width { + width: 100%; +} + +.create-post-modal { + display: flex; + flex-direction: column; + background: #0b0b0b; + border-radius: 14px; + border: 1px solid #1f1f1f; + padding: 24px; + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow: hidden; +} +.create-post-modal h2 { + color: #f5b642; + margin-bottom: 16px; + font-size: 22px; + font-weight: 700; +} + +.create-post-form-container { + display: flex; + gap: 24px; + height: calc(100% - 48px); /* leave space for title */ + overflow: hidden; +} + + +.image-column { + flex: 1; + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.form-column { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 18px; + overflow-y: auto; + padding-right: 8px; + +} + +.create-post-form{ + margin-left:2px; +} + +.create-post-form label { + display: flex; + flex-direction: column; + font-size: 14px; + color: #fff; + font-weight: 500; + margin-bottom: 8px; /* extra spacing for clarity */ +} + +.create-post-form input[type="text"], +.create-post-form textarea, +.create-post-form input[type="file"] { + margin-top: 6px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #222; + background: #121212; + color: #fff; + font-size: 14px; +} + +.create-post-form textarea { + height: 180px; /* fixed height ~9 lines */ + overflow-y: auto; + resize: none; +} + +.start-post-btn.full-width { + margin-top: 12px; +} +.create-post-form input[type="file"] { + cursor: pointer; +} + +.image-dropzone { + flex: 1; + border: 2px dashed #f5b642; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: #f5b642; + cursor: pointer; + padding: 16px; + position: relative; +} + +.image-dropzone:hover { + background: rgba(245, 182, 66, 0.1); +} + +.image-preview { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; +} + +.post-author-meta { + display: flex; + flex-direction: column; +} + +/* Post title */ +.post-title { + margin-top: 14px; + font-size: 18px; + font-weight: 700; + color: #f5b642; + line-height: 1.3; +} + +/* Post image */ +.post-image-wrapper { + margin-top: 12px; + border-radius: 12px; + overflow: hidden; + border: 1px solid #1f1f1f; + margin-bottom:6px; +} + +.post-image { + width: 100%; + max-height: 420px; + object-fit: cover; + display: block; +} + +.skeleton { + pointer-events: none; +} + +.skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #e5e7eb; +} + +.skeleton-lines { + flex: 1; +} + +.skeleton-line { + height: 12px; + background: #e5e7eb; + border-radius: 6px; + margin-bottom: 8px; + animation: pulse 1.5s infinite; +} + +.skeleton-line.short { + width: 120px; +} + +.skeleton-line.tiny { + width: 80px; +} + +.skeleton-line.title { + width: 60%; + height: 16px; +} + +.skeleton-line.image { + height: 200px; + margin-top: 12px; +} + +.avatar-initials{ + background-color: #d34500; + border-radius: 50%; + width: 35px; + height: 35px; + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + +/* Base skeleton animation */ +.skeleton-line, +.skeleton-avatar { + background: linear-gradient( + 90deg, + #878787 25%, + #545555 37%, + #171717 63% + ); + background-size: 400% 100%; + animation: skeleton-loading 1.4s ease infinite; + border-radius: 6px; +} + +@keyframes skeleton-loading { + 0% { + background-position: 100% 0; + } + 100% { + background-position: 0 0; + } +} + +/* Comment layout */ +.comment-item.skeleton { + display: flex; + gap: 12px; + padding: 12px 0; +} + +.skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; +} + +/* Header */ +.comment-header { + display: flex; + gap: 8px; + margin-bottom: 6px; +} + +.skeleton-line.name { + width: 120px; + height: 14px; +} + +.skeleton-line.username { + width: 80px; + height: 12px; + opacity: 0.7; +} + +/* Content */ +.comment-content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.skeleton-line.content { + width: 100%; + height: 14px; +} + + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +} + + + + +@media (max-width: 1024px) { + .community-layout { + flex-direction: column; + } + + .community-sidebar { + display: none; + } +} + + + +@media (max-width: 640px) { + .feed-header-inner { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .start-post-btn { + align-self: flex-end; + } + + .page-title { + font-size: 20px; + } + + .page-subtitle { + font-size: 13px; + } +} + + + +/* RESPONSIVE */ +@media (max-width: 768px) { + .navbar { + padding: 16px; + } + + .nav-links { + display: none; + } + + .post-preview { + height: 140px; + } +} + +body, +.community-page, +.community-layout { + overflow: visible !important; +} \ No newline at end of file diff --git a/client/src/Pages/community/components/CommentItem.tsx b/client/src/Pages/community/components/CommentItem.tsx new file mode 100644 index 0000000..146fa6c --- /dev/null +++ b/client/src/Pages/community/components/CommentItem.tsx @@ -0,0 +1,49 @@ +import { useToggleCommentLike } from "../hook/useToggleCommentLike"; + +const CommentItem: React.FC<{ comment: any }> = ({ comment }) => { + const toggleLike = useToggleCommentLike(comment.post_id); + console.log(comment); + return ( +
+ {comment.user?.photo_url ? ( + avatar + ) : ( + {comment.user?.first_name[0].toUpperCase()} + )} + +
+
+ {comment.user?.first_name} {comment.user?.last_name} +
+ +
{comment.content}
+ +
+ +
+
+
+ ); +}; + +export default CommentItem; diff --git a/client/src/Pages/community/components/CommentSkeleton.tsx b/client/src/Pages/community/components/CommentSkeleton.tsx new file mode 100644 index 0000000..e40399b --- /dev/null +++ b/client/src/Pages/community/components/CommentSkeleton.tsx @@ -0,0 +1,21 @@ +const CommentSkeleton = () => { + return ( +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ ); +}; + +export default CommentSkeleton; diff --git a/client/src/Pages/community/components/CommentsModal.tsx b/client/src/Pages/community/components/CommentsModal.tsx new file mode 100644 index 0000000..925a16e --- /dev/null +++ b/client/src/Pages/community/components/CommentsModal.tsx @@ -0,0 +1,96 @@ +import React, { useEffect } from "react"; +import PostCard from "./PostCard"; +import { useFetchPostComments } from "../hook/useFetchPostComments"; +import { usePostComment } from "../hook/usePostComment"; +import CommentItem from "./CommentItem"; +import CommentSkeleton from "./CommentSkeleton"; + +type Props = { + post: any; + isOpen: boolean; + onClose: () => void; +}; + +const CommentsModal: React.FC = ({ post, isOpen, onClose }) => { + const { data: comments, isLoading, isFetched } = useFetchPostComments(post.id , isOpen); + const createComment = usePostComment(); + + const isInitialLoading = isLoading && !isFetched; + + + useEffect(() => { + const esc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", esc); + return () => document.removeEventListener("keydown", esc); + }, [onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+ +
+ +
+
+ {isInitialLoading && + Array.from({ length: 3 }).map((_, i) => ( + + ))} + + {!isLoading && isFetched && comments?.length === 0 && ( +
No comments yet
+ )} + + {comments?.map((comment: any) => ( + + ))} +
+ +
{ + e.preventDefault(); + const form = e.currentTarget; + const textarea = form.elements.namedItem( + "content" + ) as HTMLTextAreaElement; + + if (!textarea.value.trim()) return; + + createComment.mutate({ + postId : post.id , + content: textarea.value + }); + textarea.value = ""; + }} + > +