feat(week8): JWT auth, SSM Parameter Store, and UI overhaul

This commit is contained in:
ikechukwu Elizabeth
2026-05-30 07:06:21 +01:00
parent 0de2d1dd42
commit dcaef8ff13
49 changed files with 8868 additions and 723 deletions
+7 -44
View File
@@ -19,22 +19,18 @@ jobs:
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: app/backend/package-lock.json cache-dependency-path: app/backend/package-lock.json
- name: Install backend dependencies - name: Install backend dependencies
working-directory: app/backend working-directory: app/backend
run: npm ci run: npm ci
- name: Run ESLint - name: Run ESLint
working-directory: app/backend working-directory: app/backend
run: npx eslint . --ext .js --max-warnings=0 run: npx eslint . --ext .js --max-warnings=0
@@ -43,22 +39,18 @@ jobs:
name: Unit Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: lint needs: lint
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: app/backend/package-lock.json cache-dependency-path: app/backend/package-lock.json
- name: Install backend dependencies - name: Install backend dependencies
working-directory: app/backend working-directory: app/backend
run: npm ci run: npm ci
- name: Run unit tests - name: Run unit tests
working-directory: app/backend working-directory: app/backend
run: npm test run: npm test
@@ -67,14 +59,11 @@ jobs:
name: Build & Integration Test name: Build & Integration Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: unit-test needs: unit-test
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build backend image - name: Build backend image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -85,7 +74,6 @@ jobs:
tags: task-backend:ci tags: task-backend:ci
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Build frontend image - name: Build frontend image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -96,10 +84,8 @@ jobs:
tags: task-frontend:ci tags: task-frontend:ci
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Start stack - name: Start stack
run: docker compose up -d run: docker compose up -d
- name: Wait for backend to be healthy - name: Wait for backend to be healthy
run: | run: |
echo "Waiting for backend health..." echo "Waiting for backend health..."
@@ -117,7 +103,6 @@ jobs:
fi fi
sleep 5 sleep 5
done done
- name: Wait for frontend to be healthy - name: Wait for frontend to be healthy
run: | run: |
echo "Waiting for frontend health..." echo "Waiting for frontend health..."
@@ -135,7 +120,6 @@ jobs:
fi fi
sleep 5 sleep 5
done done
- name: Assert backend health endpoint - name: Assert backend health endpoint
run: | run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/api/health) HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/api/health)
@@ -146,7 +130,6 @@ jobs:
exit 1 exit 1
fi fi
echo "PASSED" echo "PASSED"
- name: Assert frontend serves 200 - name: Assert frontend serves 200
run: | run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost) HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost)
@@ -157,11 +140,9 @@ jobs:
exit 1 exit 1
fi fi
echo "PASSED" echo "PASSED"
- name: Print logs on failure - name: Print logs on failure
if: failure() if: failure()
run: docker compose logs run: docker compose logs
- name: Tear down stack - name: Tear down stack
if: always() if: always()
run: docker compose down --volumes run: docker compose down --volumes
@@ -171,34 +152,27 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-and-integration-test needs: build-and-integration-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract short SHA - name: Extract short SHA
id: vars id: vars
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Configure AWS credentials via OIDC - name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform
aws-region: us-east-1 aws-region: us-east-1
- name: Log in to Amazon ECR - name: Log in to Amazon ECR
id: ecr-login id: ecr-login
uses: aws-actions/amazon-ecr-login@v2 uses: aws-actions/amazon-ecr-login@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend - name: Build and push backend
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -212,7 +186,6 @@ jobs:
${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }} ${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Build and push frontend - name: Build and push frontend
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -226,7 +199,6 @@ jobs:
${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }} ${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Job summary - name: Job summary
run: | run: |
echo "### Published Images - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY echo "### Published Images - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY
@@ -254,25 +226,20 @@ jobs:
defaults: defaults:
run: run:
working-directory: infra/terraform working-directory: infra/terraform
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC - name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform
aws-region: us-east-1 aws-region: us-east-1
- name: Setup Terraform - name: Setup Terraform
uses: hashicorp/setup-terraform@v3 uses: hashicorp/setup-terraform@v3
with: with:
terraform_version: 1.10.3 terraform_version: 1.10.3
- name: Terraform Init - name: Terraform Init
run: terraform init run: terraform init
- name: Terraform Plan - name: Terraform Plan
run: terraform plan -no-color -lock-timeout=5m run: terraform plan -no-color -lock-timeout=5m
env: env:
@@ -282,6 +249,7 @@ jobs:
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }} TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }} TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }} TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }}
TF_VAR_jwt_secret: ${{ secrets.TF_VAR_JWT_SECRET }}
terraform-apply: terraform-apply:
name: Terraform Apply name: Terraform Apply
@@ -294,26 +262,21 @@ jobs:
defaults: defaults:
run: run:
working-directory: infra/terraform working-directory: infra/terraform
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC - name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform
aws-region: us-east-1 aws-region: us-east-1
- name: Setup Terraform - name: Setup Terraform
uses: hashicorp/setup-terraform@v3 uses: hashicorp/setup-terraform@v3
with: with:
terraform_version: 1.10.3 terraform_version: 1.10.3
- name: Terraform Init - name: Terraform Init
run: terraform init run: terraform init
- name: Terraform Apply - provision SSM and infrastructure
- name: Terraform Apply — provision RDS
run: terraform apply -auto-approve -lock-timeout=5m run: terraform apply -auto-approve -lock-timeout=5m
env: env:
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }} TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
@@ -322,14 +285,13 @@ jobs:
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }} TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }} TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }} TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }}
TF_VAR_jwt_secret: ${{ secrets.TF_VAR_JWT_SECRET }}
- name: Capture RDS hostname - name: Capture RDS hostname
id: rds id: rds
run: | run: |
RDS_HOST=$(terraform output -raw rds_hostname) RDS_HOST=$(terraform output -raw rds_hostname)
echo "hostname=$RDS_HOST" >> $GITHUB_OUTPUT echo "hostname=$RDS_HOST" >> $GITHUB_OUTPUT
- name: Terraform Apply - wire EC2 with RDS hostname
- name: Terraform Apply — wire EC2 with RDS hostname
run: terraform apply -auto-approve -lock-timeout=5m run: terraform apply -auto-approve -lock-timeout=5m
env: env:
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }} TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
@@ -338,10 +300,11 @@ jobs:
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }} TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }} TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
TF_VAR_db_host: ${{ steps.rds.outputs.hostname }} TF_VAR_db_host: ${{ steps.rds.outputs.hostname }}
TF_VAR_jwt_secret: ${{ secrets.TF_VAR_JWT_SECRET }}
- name: Job summary - name: Job summary
run: | run: |
echo "### Terraform Apply - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY echo "### Terraform Apply - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "- RDS hostname: \`${{ steps.rds.outputs.hostname }}\`" >> $GITHUB_STEP_SUMMARY echo "- RDS hostname: \`${{ steps.rds.outputs.hostname }}\`" >> $GITHUB_STEP_SUMMARY
echo "- EC2 re-provisioned with DB connection wired" >> $GITHUB_STEP_SUMMARY echo "- SSM parameters provisioned" >> $GITHUB_STEP_SUMMARY
echo "- EC2 startup script fetches secrets from SSM at runtime" >> $GITHUB_STEP_SUMMARY
+118 -26
View File
@@ -1,6 +1,6 @@
# Scalable Task Platform # Scalable Task Platform
A production-grade DevOps project documenting the full lifecycle of a task management application, from a simple backend service to a containerized, automated, cloud-deployed, and observable platform. A production-grade DevOps project documenting the full lifecycle of a task management application -- from a simple backend API to a fully authenticated, containerized, cloud-deployed SaaS platform with a professional frontend.
Built in public, one week at a time. Built in public, one week at a time.
@@ -9,12 +9,17 @@ Built in public, one week at a time.
## What This Project Demonstrates ## What This Project Demonstrates
- Backend API development with Node.js and Express - Backend API development with Node.js and Express
- Containerization with Docker - PostgreSQL database integration with AWS RDS
- JWT-based user authentication and authorization
- Containerization with Docker and Docker Compose
- Multi-stage Docker builds with Nginx reverse proxy
- CI/CD automation with GitHub Actions, Amazon ECR, and Docker Hub - CI/CD automation with GitHub Actions, Amazon ECR, and Docker Hub
- Infrastructure as Code with Terraform - Infrastructure as Code with Terraform
- Cloud deployment on AWS EC2 - Secrets management with AWS SSM Parameter Store
- Container orchestration with Kubernetes - Cloud deployment on AWS EC2 with SSM-only access
- Monitoring and observability with Prometheus and Grafana - Professional React frontend with Tailwind CSS
- Container orchestration with Kubernetes (upcoming)
- Monitoring and observability with Prometheus and Grafana (upcoming)
--- ---
@@ -22,14 +27,19 @@ Built in public, one week at a time.
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Frontend | React, Vite, Tailwind CSS, React Router |
| Backend | Node.js, Express | | Backend | Node.js, Express |
| Database | PostgreSQL on AWS RDS |
| Authentication | JWT, bcryptjs |
| Containerization | Docker, Docker Compose | | Containerization | Docker, Docker Compose |
| Reverse Proxy | Nginx |
| CI/CD | GitHub Actions | | CI/CD | GitHub Actions |
| Image Registry | Amazon ECR, Docker Hub | | Image Registry | Amazon ECR, Docker Hub |
| Infrastructure | Terraform | | Infrastructure | Terraform |
| Cloud | AWS EC2 | | Secrets | AWS SSM Parameter Store |
| Orchestration | Kubernetes | | Cloud | AWS EC2, RDS, VPC, IAM |
| Monitoring | Prometheus, Grafana | | Orchestration | Kubernetes (upcoming) |
| Monitoring | Prometheus, Grafana (upcoming) |
--- ---
@@ -43,23 +53,81 @@ Built in public, one week at a time.
| 4 | CI/CD pipeline with GitHub Actions, ECR and Docker Hub | [Done](docs/week-4-update.md) | | 4 | CI/CD pipeline with GitHub Actions, ECR and Docker Hub | [Done](docs/week-4-update.md) |
| 5 | Terraform infrastructure and CI/CD pipeline extension | [Done](docs/week-5-update.md) | | 5 | Terraform infrastructure and CI/CD pipeline extension | [Done](docs/week-5-update.md) |
| 6 | Cloud deployment on AWS EC2 | [Done](docs/week-6-update.md) | | 6 | Cloud deployment on AWS EC2 | [Done](docs/week-6-update.md) |
| 7 | Database integration | Upcoming | | 7 | PostgreSQL on AWS RDS with Terraform | [Done](docs/week-7-update.md) |
| 8 | Kubernetes deployment | Upcoming | | 8 | JWT authentication, SSM Parameter Store, UI overhaul | [Done](docs/week-8-update.md) |
| 9 | Monitoring with Prometheus and Grafana | Upcoming | | 9 | Kubernetes deployment | Upcoming |
| 10 | Hardening and project polish | Upcoming | | 10 | Hardening and project polish | Upcoming |
--- ---
## API Endpoints ## API Endpoints
### Health
| Method | Route | Description | | Method | Route | Description |
|---|---|---| |---|---|---|
| GET | /api | API status | | GET | /api | API status |
| GET | /api/health | Returns status and uptime | | GET | /api/health | Returns status and uptime |
| GET | /api/tasks | Returns all tasks |
| POST | /api/tasks | Creates a new task | ### Authentication
| PATCH | /api/tasks/:id | Toggles task complete or incomplete |
| DELETE | /api/tasks/:id | Deletes a task | | Method | Route | Description | Auth |
|---|---|---|---|
| POST | /api/auth/register | Create a new user account | Public |
| POST | /api/auth/login | Sign in and receive JWT token | Public |
| GET | /api/auth/me | Returns current authenticated user | Required |
### Tasks
| Method | Route | Description | Auth |
|---|---|---|---|
| GET | /api/tasks | Returns all tasks for the authenticated user | Required |
| POST | /api/tasks | Creates a new task | Required |
| PATCH | /api/tasks/:id | Toggles task complete or incomplete | Required |
| DELETE | /api/tasks/:id | Deletes a task | Required |
### Projects
| Method | Route | Description | Auth |
|---|---|---|---|
| GET | /api/projects | Returns all projects for the authenticated user | Required |
| POST | /api/projects | Creates a new project | Required |
| DELETE | /api/projects/:id | Deletes a project | Required |
### Team
| Method | Route | Description | Auth |
|---|---|---|---|
| GET | /api/team | Returns all team members for the workspace | Required |
| POST | /api/team/invite | Invites a team member by email | Required |
| DELETE | /api/team/:id | Removes a team member | Required |
---
## Authentication
All protected routes require a `Bearer` token in the `Authorization` header:
```
Authorization: Bearer <your-jwt-token>
```
Tokens are issued on login and registration. They expire after 7 days.
Passwords are hashed with bcrypt at cost factor 12. Plain text passwords are never stored.
---
## Frontend
The frontend is a React + Vite + Tailwind CSS application with 26 pages including:
- Landing page with feature sections
- Authentication pages (login and register)
- Full dashboard with sidebar navigation
- Dashboard pages: Tasks, Projects, Teams, Calendar, Activity, Notifications, Analytics, Settings
- Feature detail pages for all 7 platform features
- Marketing pages: Pricing, About, Contact, Status
--- ---
@@ -71,7 +139,7 @@ Built in public, one week at a time.
- npm - npm
- Docker and Docker Compose - Docker and Docker Compose
### Run Locally Without Docker ### Run Backend Locally
```bash ```bash
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
@@ -83,7 +151,17 @@ npm start
Server runs on http://localhost:3000 Server runs on http://localhost:3000
### Run With Docker Compose ### Run Frontend Locally
```bash
cd scalable-task-platform/app/frontend
npm install
npm run dev
```
Frontend runs on http://localhost:5173
### Run Full Stack With Docker Compose
```bash ```bash
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
@@ -91,7 +169,7 @@ cd scalable-task-platform
docker compose up -d docker compose up -d
``` ```
Frontend runs on http://localhost:80 Full stack runs on http://localhost:80
--- ---
@@ -103,11 +181,11 @@ Every pull request and push to `main` triggers a five-stage pipeline:
lint -> unit-test -> build-and-integration-test -> publish -> terraform-apply lint -> unit-test -> build-and-integration-test -> publish -> terraform-apply
``` ```
- `terraform-plan` runs on every pull request — shows exactly what infrastructure will change before merging - `terraform-plan` runs on every pull request -- shows exactly what infrastructure will change before merging
- `terraform-apply` runs on merge to `main` — infrastructure is provisioned by the pipeline, never from a local machine - `terraform-apply` runs on merge to `main` -- provisions SSM parameters, infrastructure, and deploys to EC2
- The `publish` stage pushes images to both Amazon ECR and Docker Hub, tagged with `latest` and the short Git SHA - The `publish` stage pushes images to both Amazon ECR and Docker Hub tagged with `latest` and the short Git SHA
AWS authentication uses OIDC — no static credentials stored in GitHub secrets. AWS authentication uses OIDC -- no static credentials stored in GitHub secrets.
--- ---
@@ -117,14 +195,28 @@ All infrastructure is provisioned with Terraform and lives in `infra/terraform/`
| Resource | Details | | Resource | Details |
|---|---| |---|---|
| VPC | Custom VPC with public subnet | | VPC | Custom VPC with public and private subnets |
| Networking | Internet gateway, route tables | | Networking | Internet gateway, route tables |
| Security | Security groups with least-privilege rules | | Security | Security groups with least-privilege rules |
| Compute | EC2 t3.micro running Amazon Linux 2023 | | Compute | EC2 t3.micro running Amazon Linux 2023 |
| IAM | Instance profile with SSM and ECR permissions | | Database | RDS PostgreSQL 16 on db.t3.micro in private subnet |
| Secrets | SSM Parameter Store with SecureString encryption |
| IAM | Instance profile scoped to SSM parameters and ECR |
| State | S3 remote backend with native state locking | | State | S3 remote backend with native state locking |
EC2 instances configure themselves on first boot via a user data script — Docker is installed, images are pulled from ECR, and containers start automatically. No SSH required. All access via AWS SSM Session Manager. EC2 instances configure themselves on first boot via a user data script -- Docker is installed, secrets are fetched from SSM Parameter Store at runtime, images are pulled from ECR, and containers start automatically. No SSH required. All access via AWS SSM Session Manager.
---
## Security
- JWT authentication on all protected API routes
- Passwords hashed with bcrypt (cost factor 12)
- Database credentials stored in AWS SSM Parameter Store -- never in code or environment files
- EC2 has no SSH access -- management via SSM Session Manager only
- RDS in private subnet -- no public internet access
- IMDSv2 enforced on EC2 metadata endpoint
- EBS volumes encrypted at rest
--- ---
@@ -140,4 +232,4 @@ Ikechukwu Elizabeth Nkwo
DevOps and Cloud Engineer DevOps and Cloud Engineer
[LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth) [LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth) | [GitHub](https://github.com/elizabeth-ikechukwu)
+57 -17
View File
@@ -1,36 +1,76 @@
const { Pool } = require("pg"); const { Pool } = require('pg')
// Connection pool — reuses connections instead of
// opening a new one for every request
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME, database: process.env.DB_NAME,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
ssl: { ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
rejectUnauthorized: false, // Required for AWS RDS })
},
});
// Create the tasks table if it does not already exist. // In CI there is no real database - the error is logged
// Runs once on server startup.
// In CI there is no real database — the error is logged
// and the server starts anyway so the health check passes. // and the server starts anyway so the health check passes.
async function initDb() { async function initDb() {
try { try {
// Users table - must exist before tasks (foreign key)
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// Tasks table
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE, completed BOOLEAN NOT NULL DEFAULT FALSE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
`); `)
console.log("Database initialised — tasks table ready");
// Add user_id to tasks if upgrading from Week 7
await pool.query(`
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
`)
// Projects table
await pool.query(`
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// Team members table
await pool.query(`
CREATE TABLE IF NOT EXISTS team_members (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(owner_id, email)
);
`)
console.log('Database initialised - all tables ready')
} catch (err) { } catch (err) {
console.warn("Database not available — running without persistent storage:", err.message); console.warn(
'Database not available - running without persistent storage:',
err.message
)
} }
} }
module.exports = { pool, initDb }; module.exports = { pool, initDb }
+24
View File
@@ -0,0 +1,24 @@
const jwt = require("jsonwebtoken");
const JWT_SECRET = process.env.JWT_SECRET || "changeme-use-a-real-secret-in-production";
// Protects routes - attaches req.user if token is valid
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = header.split(" ")[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // { id, email, name }
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
module.exports = { requireAuth, JWT_SECRET };
+138 -12
View File
@@ -8,8 +8,10 @@
"name": "task-backend", "name": "task-backend",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3" "pg": "^8.13.3"
}, },
"devDependencies": { "devDependencies": {
@@ -2087,10 +2089,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@@ -2101,7 +2109,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -2168,6 +2176,12 @@
"node-int64": "^0.4.0" "node-int64": "^0.4.0"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2639,6 +2653,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3026,14 +3049,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -3052,7 +3075,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -4474,6 +4497,67 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4531,6 +4615,48 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5278,9 +5404,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
+3 -1
View File
@@ -10,8 +10,10 @@
"lint": "eslint . --ext .js --max-warnings=0" "lint": "eslint . --ext .js --max-warnings=0"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3" "pg": "^8.13.3"
}, },
"devDependencies": { "devDependencies": {
@@ -20,4 +22,4 @@
"jest": "^30.3.0", "jest": "^30.3.0",
"supertest": "^7.2.2" "supertest": "^7.2.2"
} }
} }
+112
View File
@@ -0,0 +1,112 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { pool } = require("../db");
const { requireAuth, JWT_SECRET } = require("../middleware/auth");
const router = express.Router();
// Helper - sign a JWT for a user row
function signToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, name: user.name },
JWT_SECRET,
{ expiresIn: "7d" }
);
}
// POST /api/auth/register
router.post("/register", async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: "Name, email, and password are required" });
}
if (password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
}
try {
// Check if email already exists
const existing = await pool.query(
"SELECT id FROM users WHERE email = $1",
[email.toLowerCase().trim()]
);
if (existing.rowCount > 0) {
return res.status(409).json({ error: "An account with that email already exists" });
}
// Hash password
const hashed = await bcrypt.hash(password, 12);
// Insert user
const result = await pool.query(
"INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email, created_at",
[name.trim(), email.toLowerCase().trim(), hashed]
);
const user = result.rows[0];
const token = signToken(user);
res.status(201).json({ token, user: { id: user.id, name: user.name, email: user.email } });
} catch (err) {
console.error("POST /api/auth/register error:", err);
res.status(500).json({ error: "Registration failed" });
}
});
// POST /api/auth/login
router.post("/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
try {
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email.toLowerCase().trim()]
);
if (result.rowCount === 0) {
return res.status(401).json({ error: "Invalid email or password" });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password);
if (!match) {
return res.status(401).json({ error: "Invalid email or password" });
}
const token = signToken(user);
res.json({ token, user: { id: user.id, name: user.name, email: user.email } });
} catch (err) {
console.error("POST /api/auth/login error:", err);
res.status(500).json({ error: "Login failed" });
}
});
// GET /api/auth/me - returns current user from token
router.get("/me", requireAuth, async (req, res) => {
try {
const result = await pool.query(
"SELECT id, name, email, created_at FROM users WHERE id = $1",
[req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("GET /api/auth/me error:", err);
res.status(500).json({ error: "Failed to fetch user" });
}
});
module.exports = router;
+60
View File
@@ -0,0 +1,60 @@
const express = require('express')
const { pool } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
// All project routes require a valid JWT
router.use(requireAuth)
// GET /api/projects - return all projects for the logged-in user
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id]
)
res.json(result.rows)
} catch (err) {
console.error('GET /api/projects error:', err)
res.status(500).json({ error: 'Failed to fetch projects' })
}
})
// POST /api/projects - create a new project
router.post('/', async (req, res) => {
const { name, description } = req.body
if (!name) {
return res.status(400).json({ error: 'Project name is required' })
}
try {
const result = await pool.query(
'INSERT INTO projects (name, description, user_id) VALUES ($1, $2, $3) RETURNING *',
[name.trim(), description || '', req.user.id]
)
res.status(201).json(result.rows[0])
} catch (err) {
console.error('POST /api/projects error:', err)
res.status(500).json({ error: 'Failed to create project' })
}
})
// DELETE /api/projects/:id - delete a project (owner only)
router.delete('/:id', async (req, res) => {
const { id } = req.params
try {
const result = await pool.query(
'DELETE FROM projects WHERE id = $1 AND user_id = $2',
[id, req.user.id]
)
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Project not found' })
}
res.status(204).send()
} catch (err) {
console.error('DELETE /api/projects/:id error:', err)
res.status(500).json({ error: 'Failed to delete project' })
}
})
module.exports = router
+81
View File
@@ -0,0 +1,81 @@
const express = require("express");
const { pool } = require("../db");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
// All task routes require a valid JWT
router.use(requireAuth);
// GET /api/tasks - return tasks for the logged-in user, newest first
router.get("/", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM tasks WHERE user_id = $1 ORDER BY created_at DESC",
[req.user.id]
);
res.json(result.rows);
} catch (err) {
console.error("GET /api/tasks error:", err);
res.status(500).json({ error: "Failed to fetch tasks" });
}
});
// POST /api/tasks - create a task for the logged-in user
router.post("/", async (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: "Title is required" });
}
try {
const result = await pool.query(
"INSERT INTO tasks (title, user_id) VALUES ($1, $2) RETURNING *",
[title, req.user.id]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error("POST /api/tasks error:", err);
res.status(500).json({ error: "Failed to create task" });
}
});
// PATCH /api/tasks/:id - toggle completed (only owner can update)
router.patch("/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE tasks
SET completed = NOT completed
WHERE id = $1 AND user_id = $2
RETURNING *`,
[id, req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("PATCH /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to update task" });
}
});
// DELETE /api/tasks/:id - delete (only owner can delete)
router.delete("/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
"DELETE FROM tasks WHERE id = $1 AND user_id = $2",
[id, req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.status(204).send();
} catch (err) {
console.error("DELETE /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to delete task" });
}
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const express = require('express')
const { pool } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
// All team routes require a valid JWT
router.use(requireAuth)
// GET /api/team - return all team members for the logged-in user's workspace
router.get('/', async (req, res) => {
try {
const result = await pool.query(
`SELECT tm.id, tm.email, tm.role, tm.created_at,
u.name
FROM team_members tm
LEFT JOIN users u ON u.email = tm.email
WHERE tm.owner_id = $1
ORDER BY tm.created_at ASC`,
[req.user.id]
)
res.json(result.rows)
} catch (err) {
console.error('GET /api/team error:', err)
res.status(500).json({ error: 'Failed to fetch team members' })
}
})
// POST /api/team/invite - invite a team member by email
router.post('/invite', async (req, res) => {
const { email, role } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
const validRoles = ['admin', 'member', 'viewer']
const memberRole = validRoles.includes(role) ? role : 'member'
try {
// Check if already invited
const existing = await pool.query(
'SELECT id FROM team_members WHERE owner_id = $1 AND email = $2',
[req.user.id, email.toLowerCase().trim()]
)
if (existing.rowCount > 0) {
return res.status(409).json({ error: 'This person has already been invited' })
}
const result = await pool.query(
'INSERT INTO team_members (owner_id, email, role) VALUES ($1, $2, $3) RETURNING *',
[req.user.id, email.toLowerCase().trim(), memberRole]
)
res.status(201).json(result.rows[0])
} catch (err) {
console.error('POST /api/team/invite error:', err)
res.status(500).json({ error: 'Failed to send invitation' })
}
})
// DELETE /api/team/:id - remove a team member
router.delete('/:id', async (req, res) => {
const { id } = req.params
try {
const result = await pool.query(
'DELETE FROM team_members WHERE id = $1 AND owner_id = $2',
[id, req.user.id]
)
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Team member not found' })
}
res.status(204).send()
} catch (err) {
console.error('DELETE /api/team/:id error:', err)
res.status(500).json({ error: 'Failed to remove team member' })
}
})
module.exports = router
+37 -100
View File
@@ -1,124 +1,61 @@
require("dotenv").config(); require('dotenv').config()
const express = require("express"); const express = require('express')
const { pool, initDb } = require("./db"); const { initDb } = require('./db')
const authRoutes = require('./routes/authRoutes')
const taskRoutes = require('./routes/taskRoutes')
const projectRoutes = require('./routes/projectRoutes')
const teamRoutes = require('./routes/teamRoutes')
const app = express(); const app = express()
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000
app.use(express.json()); app.use(express.json())
// ── Routes ──────────────────────────────────── // -- Health and root ----------------------------------
app.get("/api", (req, res) => { app.get('/api', (req, res) => {
res.json({ message: "Task API is running", version: "2.0.0" }); res.json({ message: 'Task API is running', version: '3.0.0' })
}); })
app.get("/api/health", (req, res) => { app.get('/api/health', (req, res) => {
res.status(200).json({ res.status(200).json({
status: "ok", status: 'ok',
uptime: process.uptime(), uptime: process.uptime(),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); })
}); })
// GET /api/tasks — return all tasks, newest first // -- Routes -------------------------------------------
app.get("/api/tasks", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM tasks ORDER BY created_at DESC"
);
res.json(result.rows);
} catch (err) {
console.error("GET /api/tasks error:", err);
res.status(500).json({ error: "Failed to fetch tasks" });
}
});
// POST /api/tasks — create a new task app.use('/api/auth', authRoutes)
app.post("/api/tasks", async (req, res) => { app.use('/api/tasks', taskRoutes)
const { title } = req.body; app.use('/api/projects', projectRoutes)
if (!title) { app.use('/api/team', teamRoutes)
return res.status(400).json({ error: "Title is required" });
}
try {
const result = await pool.query(
"INSERT INTO tasks (title) VALUES ($1) RETURNING *",
[title]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error("POST /api/tasks error:", err);
res.status(500).json({ error: "Failed to create task" });
}
});
// PATCH /api/tasks/:id — toggle completed status // -- Error handlers -----------------------------------
app.patch("/api/tasks/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE tasks
SET completed = NOT completed
WHERE id = $1
RETURNING *`,
[id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("PATCH /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to update task" });
}
});
// DELETE /api/tasks/:id — delete a task
app.delete("/api/tasks/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
"DELETE FROM tasks WHERE id = $1",
[id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.status(204).send();
} catch (err) {
console.error("DELETE /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to delete task" });
}
});
// ── Error handlers ────────────────────────────
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: "Route not found" }); res.status(404).json({ error: 'Route not found' })
}); })
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
console.error(err.stack); console.error(err.stack)
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: 'Internal server error' })
}); })
// -- Start --------------------------------------------
// ── Start ─────────────────────────────────────
// initDb runs first — creates the tasks table if it
// does not exist, then starts the server.
// Wrapped in require.main check so Jest can import
// the app without starting the server or connecting
// to the database.
if (require.main === module) { if (require.main === module) {
initDb() initDb()
.then(() => { .then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`)
}); })
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to initialise database:", err); console.error('Failed to initialise database:', err)
process.exit(1); process.exit(1)
}); })
} }
module.exports = app; module.exports = app
+3172
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -10,10 +10,15 @@
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.5" "vite": "^6.0.5"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+57 -148
View File
@@ -1,155 +1,64 @@
import { useEffect, useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Landing from './pages/Landing'
const API = '/api' import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import Projects from './pages/Projects'
import Tasks from './pages/Tasks'
import Teams from './pages/Teams'
import Calendar from './pages/Calendar'
import Activity from './pages/Activity'
import Notifications from './pages/Notifications'
import Analytics from './pages/Analytics'
import Settings from './pages/Settings'
import Pricing from './pages/Pricing'
import About from './pages/About'
import Contact from './pages/Contact'
import Status from './pages/Status'
import TaskManagement from './pages/features/TaskManagement'
import TeamCollaboration from './pages/features/TeamCollaboration'
import NotificationsFeature from './pages/features/Notifications'
import ProjectWorkspaces from './pages/features/ProjectWorkspaces'
import AnalyticsFeature from './pages/features/Analytics'
import Security from './pages/features/Security'
import Architecture from './pages/features/Architecture'
export default function App() { export default function App() {
const [tasks, setTasks] = useState([])
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(true)
const [adding, setAdding] = useState(false)
const fetchTasks = async () => {
const res = await fetch(`${API}/tasks`)
const data = await res.json()
setTasks(data)
setLoading(false)
}
useEffect(() => { fetchTasks() }, [])
const addTask = async () => {
if (!title.trim()) return
setAdding(true)
await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
})
setTitle('')
setAdding(false)
fetchTasks()
}
const toggleTask = async (id) => {
await fetch(`${API}/tasks/${id}`, { method: 'PATCH' })
fetchTasks()
}
const deleteTask = async (id) => {
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' })
fetchTasks()
}
const completed = tasks.filter(t => t.completed).length
const remaining = tasks.filter(t => !t.completed).length
const progress = tasks.length > 0 ? (completed / tasks.length) * 100 : 0
return ( return (
<div className="app"> <BrowserRouter>
<Routes>
{/* Public */}
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/status" element={<Status />} />
{/* LEFT PANEL */} {/* Feature detail pages */}
<div className="sidebar"> <Route path="/features/task-management" element={<TaskManagement />} />
<div className="sidebar-top"> <Route path="/features/team-collaboration" element={<TeamCollaboration />} />
<div className="brand"> <Route path="/features/notifications" element={<NotificationsFeature />} />
<div className="brand-icon">✓</div> <Route path="/features/project-workspaces" element={<ProjectWorkspaces />} />
<div> <Route path="/features/analytics" element={<AnalyticsFeature />} />
<h1>TaskFlow</h1> <Route path="/features/security" element={<Security />} />
<p className="brand-sub">Personal workspace</p> <Route path="/features/architecture" element={<Architecture />} />
</div>
</div>
<div className="stats"> {/* Dashboard pages */}
<div className="stat-card"> <Route path="/dashboard" element={<Dashboard />} />
<span className="stat-number">{tasks.length}</span> <Route path="/dashboard/projects" element={<Projects />} />
<span className="stat-label">Total</span> <Route path="/dashboard/tasks" element={<Tasks />} />
</div> <Route path="/dashboard/teams" element={<Teams />} />
<div className="stat-card accent"> <Route path="/dashboard/calendar" element={<Calendar />} />
<span className="stat-number">{remaining}</span> <Route path="/dashboard/activity" element={<Activity />} />
<span className="stat-label">Remaining</span> <Route path="/dashboard/notifications" element={<Notifications />} />
</div> <Route path="/dashboard/analytics" element={<Analytics />} />
<div className="stat-card green"> <Route path="/dashboard/settings" element={<Settings />} />
<span className="stat-number">{completed}</span>
<span className="stat-label">Done</span>
</div>
</div>
<div className="progress-section"> {/* Fallback */}
<div className="progress-header"> <Route path="*" element={<Navigate to="/" replace />} />
<span>Progress</span> </Routes>
<span>{Math.round(progress)}%</span> </BrowserRouter>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
<div className="sidebar-footer">
<p className="built-by">Built with Node.js · Docker · Nginx</p>
<p className="built-by">Deployed via Docker Compose</p>
</div>
</div>
{/* RIGHT PANEL */}
<div className="main">
<div className="main-header">
<div>
<h2>My Tasks</h2>
<p className="main-sub">Stay focused. Ship things.</p>
</div>
</div>
<div className="input-area">
<input
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTask()}
placeholder="What needs to be done? Press Enter or click Add..."
/>
<button className="btn-add" onClick={addTask} disabled={adding}>
{adding ? '...' : '+ Add Task'}
</button>
</div>
<div className="task-section">
{loading && <p className="empty">Loading your tasks...</p>}
{!loading && tasks.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📋</div>
<p className="empty-title">No tasks yet</p>
<p className="empty-desc">Add your first task above to get started</p>
</div>
)}
{tasks.filter(t => !t.completed).length > 0 && (
<div className="task-group">
<p className="group-label">● Active</p>
{tasks.filter(t => !t.completed).map(task => (
<div key={task.id} className="task-item">
<button className="check-btn" onClick={() => toggleTask(task.id)} title="Mark complete" />
<span className="task-title">{task.title}</span>
<button className="delete-btn" onClick={() => deleteTask(task.id)}>Delete</button>
</div>
))}
</div>
)}
{tasks.filter(t => t.completed).length > 0 && (
<div className="task-group">
<p className="group-label done-label">✓ Completed</p>
{tasks.filter(t => t.completed).map(task => (
<div key={task.id} className="task-item completed">
<button className="check-btn checked" onClick={() => toggleTask(task.id)} title="Mark incomplete">✓</button>
<span className="task-title">{task.title}</span>
<button className="delete-btn" onClick={() => deleteTask(task.id)}>Delete</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
) )
} }
@@ -0,0 +1,93 @@
/* Reusable empty state components
* Usage: <EmptyTasks onAdd={fn} /> <EmptyProjects onAdd={fn} /> etc.
*/
function EmptyBase({ icon, title, description, ctaLabel, onAction }) {
return (
<div className="flex flex-col items-center justify-center py-20 px-6 text-center animate-fade-up">
<div className="w-16 h-16 rounded-2xl bg-surface2 border border-border flex items-center justify-center mb-5 text-2xl">
{icon}
</div>
<h3 className="font-display font-700 text-text1 text-lg mb-2">{title}</h3>
<p className="text-sm text-text2 max-w-xs leading-relaxed mb-6">{description}</p>
{ctaLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-600 rounded-lg transition-all duration-150 shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5 active:translate-y-0"
>
{ctaLabel}
</button>
)}
</div>
)
}
export function EmptyTasks({ onAdd }) {
return (
<EmptyBase
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
}
title="No Tasks Yet"
description="You don't have any tasks. Add your first task to start tracking your work and making progress."
ctaLabel="+ Add Your First Task"
onAction={onAdd}
/>
)
}
export function EmptyProjects({ onAdd }) {
return (
<EmptyBase
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 7a2 2 0 012-2h4l2 3h9a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V7z"/>
</svg>
}
title="No Projects Found"
description="You haven't created any projects yet. Organize your tasks into projects to track progress across different workstreams."
ctaLabel="Create First Project"
onAction={onAdd}
/>
)
}
export function EmptyNotifications({ }) {
return (
<EmptyBase
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
}
title="No Notifications"
description="You're all caught up. New activity from your projects, team, and tasks will appear here."
/>
)
}
export function EmptyTeam({ onInvite }) {
return (
<EmptyBase
icon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
}
title="No Team Members Added"
description="Your workspace is currently solo. Invite your team to collaborate on tasks, share projects, and move faster together."
ctaLabel="Invite Team Member"
onAction={onInvite}
/>
)
}
/* Default export for convenience */
export default { EmptyTasks, EmptyProjects, EmptyNotifications, EmptyTeam }
@@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
export default function FeatureCard({ title, description, icon, color, bg, to, index = 0 }) {
return (
<Link
to={to}
className="group block p-5 rounded-xl border bg-surface hover:bg-surface2 transition-all duration-200 hover:-translate-y-1.5 hover:shadow-xl hover:shadow-accent/10 cursor-pointer animate-fade-up"
style={{ animationDelay: `${index * 0.07}s` }}
>
{/* Icon */}
<div className={`w-11 h-11 rounded-xl border flex items-center justify-center mb-4 ${bg} ${color} transition-all duration-200 group-hover:scale-110 group-hover:shadow-lg`}>
{icon}
</div>
{/* Title */}
<h3 className="font-display font-700 text-text1 text-sm mb-2 tracking-tight group-hover:text-accent transition-colors duration-150">
{title}
</h3>
{/* Description */}
<p className="text-xs text-text2 leading-relaxed mb-4">{description}</p>
{/* Learn more link */}
<div className="flex items-center gap-1.5 text-xs font-600 text-text3 group-hover:text-accent transition-colors duration-150">
<span>Learn more</span>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
className="transition-transform duration-150 group-hover:translate-x-1"
>
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</div>
</Link>
)
}
@@ -0,0 +1,78 @@
import { Link } from 'react-router-dom'
import Footer from './Footer'
export default function FeaturePageLayout({ hero, children }) {
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<Link to="/#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</Link>
<Link to="/dashboard" className="text-sm text-text2 hover:text-text1 transition-colors">Dashboard</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Breadcrumb */}
<div className="border-b border-border">
<div className="max-w-6xl mx-auto px-6 py-3 flex items-center gap-2 text-xs text-text3">
<Link to="/" className="hover:text-text2 transition-colors">Home</Link>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
<Link to="/#features" className="hover:text-text2 transition-colors">Features</Link>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
<span className="text-text2">{hero.title}</span>
</div>
</div>
{/* Hero */}
<section className="relative overflow-hidden border-b border-border">
<div className="absolute inset-0 bg-[linear-gradient(rgba(99,102,241,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(99,102,241,0.04)_1px,transparent_1px)] bg-[size:60px_60px]" />
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-accent/8 rounded-full blur-[100px] translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="relative z-10 max-w-6xl mx-auto px-6 py-20">
<div className="flex items-center gap-3 mb-6">
<div className={`w-12 h-12 rounded-2xl border flex items-center justify-center ${hero.bg} ${hero.color}`}>
{hero.icon}
</div>
<span className="text-xs font-700 text-accent uppercase tracking-widest">{hero.category}</span>
</div>
<h1 className="font-display font-800 text-4xl md:text-5xl text-text1 leading-tight tracking-tight mb-5 max-w-3xl">
{hero.title}
</h1>
<p className="text-base text-text2 leading-relaxed max-w-2xl mb-8">{hero.description}</p>
<div className="flex gap-4">
<Link to="/register" className="px-6 py-3 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Free
</Link>
<Link to="/dashboard" className="px-6 py-3 bg-surface hover:bg-surface2 text-text1 font-600 rounded-xl border border-border text-sm transition-all">
View Demo
</Link>
</div>
</div>
</section>
{/* Page content */}
<main className="flex-1">
{children}
</main>
<Footer />
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { Link } from 'react-router-dom'
const FOOTER_LINKS = {
Product: [
{ label: 'Features', to: '/#features' },
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Pricing', to: '/pricing' },
],
Company: [
{ label: 'About', to: '/about' },
{ label: 'Contact', to: '/contact' },
],
Resources: [
{ label: 'Status', to: '/status' },
{ label: 'GitHub', href: 'https://github.com/elizabeth-ikechukwu/scalable-task-platform' },
],
}
function LinkedInIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2z"/>
<circle cx="4" cy="4" r="2"/>
</svg>
)
}
function GitHubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.202 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
)
}
export default function Footer() {
return (
<footer className="bg-surface border-t border-border mt-auto">
<div className="max-w-6xl mx-auto px-8 py-16">
{/* Top row */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-12 mb-12">
{/* Brand block */}
<div className="md:col-span-2">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-accent flex items-center justify-center glow-pulse flex-shrink-0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 text-lg tracking-tight">TaskFlow</span>
</div>
<p className="text-sm text-text2 leading-relaxed max-w-xs">
A scalable task management platform designed to simplify collaboration, improve productivity, and help teams deliver work efficiently at any scale.
</p>
{/* Social icons - LinkedIn and GitHub only */}
<div className="flex items-center gap-3 mt-6">
<a
href="https://www.linkedin.com/in/ikechukwu-elizabeth"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="w-9 h-9 rounded-lg bg-surface2 border border-border text-text3 hover:text-accent hover:border-accent/30 hover:bg-accent-dim flex items-center justify-center transition-all duration-150"
>
<LinkedInIcon />
</a>
<a
href="https://github.com/elizabeth-ikechukwu"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="w-9 h-9 rounded-lg bg-surface2 border border-border text-text3 hover:text-accent hover:border-accent/30 hover:bg-accent-dim flex items-center justify-center transition-all duration-150"
>
<GitHubIcon />
</a>
</div>
</div>
{/* Nav link groups */}
{Object.entries(FOOTER_LINKS).map(([group, links]) => (
<div key={group}>
<p className="text-[0.7rem] font-700 text-text3 uppercase tracking-widest mb-4">{group}</p>
<ul className="flex flex-col gap-2.5">
{links.map(({ label, to, href }) => (
<li key={label}>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-text2 hover:text-text1 transition-colors duration-150"
>
{label}
</a>
) : (
<Link
to={to}
className="text-sm text-text2 hover:text-text1 transition-colors duration-150"
>
{label}
</Link>
)}
</li>
))}
</ul>
</div>
))}
</div>
{/* Bottom row */}
<div className="pt-8 border-t border-border flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-xs text-text3">
&copy; {new Date().getFullYear()} TaskFlow. Built by{' '}
<a
href="https://www.linkedin.com/in/ikechukwu-elizabeth"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover transition-colors"
>
Elizabeth Ikechukwu
</a>
.
</p>
<div className="flex items-center gap-1.5 text-xs text-text3">
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse inline-block" />
All systems operational
</div>
</div>
</div>
</footer>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { NavLink, useNavigate } from 'react-router-dom'
const NAV_ITEMS = [
{
label: 'Dashboard',
to: '/dashboard',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
),
},
{
label: 'Projects',
to: '/dashboard/projects',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 7a2 2 0 012-2h4l2 3h9a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V7z"/>
</svg>
),
},
{
label: 'Tasks',
to: '/dashboard/tasks',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
),
},
{
label: 'Teams',
to: '/dashboard/teams',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
),
},
{
label: 'Calendar',
to: '/dashboard/calendar',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
},
{
label: 'Activity',
to: '/dashboard/activity',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
),
},
{
label: 'Notifications',
to: '/dashboard/notifications',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
),
badge: 3,
},
{
label: 'Analytics',
to: '/dashboard/analytics',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
},
{
label: 'Settings',
to: '/dashboard/settings',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
),
},
]
function getUserFromToken() {
try {
const token = localStorage.getItem('token')
if (!token) return null
const payload = JSON.parse(atob(token.split('.')[1]))
return payload
} catch {
return null
}
}
export default function Sidebar() {
const navigate = useNavigate()
const user = getUserFromToken()
const handleLogout = () => {
localStorage.removeItem('token')
navigate('/')
}
const initials = user?.name
? user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
: 'U'
return (
<aside className="fixed left-0 top-0 h-screen w-[240px] bg-surface border-r border-border flex flex-col z-40">
<div className="px-6 py-6 border-b border-border">
<NavLink to="/dashboard" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-accent flex items-center justify-center flex-shrink-0 glow-pulse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<div>
<p className="font-display font-800 text-text1 text-[1rem] leading-none tracking-tight">TaskFlow</p>
<p className="text-[0.65rem] text-text3 font-500 mt-1 uppercase tracking-widest">Workspace</p>
</div>
</NavLink>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<p className="px-3 mb-2 text-[0.62rem] font-700 text-text3 uppercase tracking-widest">Menu</p>
<ul className="flex flex-col gap-0.5">
{NAV_ITEMS.map((item, i) => (
<li key={item.label}>
<NavLink
to={item.to}
end={item.to === '/dashboard'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-500 transition-all duration-150 group relative
${isActive
? 'bg-accent-dim text-accent border border-accent/20'
: 'text-text2 hover:text-text1 hover:bg-surface2 border border-transparent'
}`
}
style={{ animationDelay: `${i * 0.04}s` }}
>
<span className="flex-shrink-0">{item.icon}</span>
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="bg-accent text-white text-[0.6rem] font-700 px-1.5 py-0.5 rounded-full leading-none">
{item.badge}
</span>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="px-4 py-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-surface2 transition-colors group mb-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-accent to-green flex items-center justify-center text-white text-xs font-700 flex-shrink-0">
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-600 text-text1 truncate">{user?.name || 'Guest'}</p>
<p className="text-[0.65rem] text-text3 truncate">{user?.email || 'Not signed in'}</p>
</div>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg text-xs font-600 text-text3 hover:text-red hover:bg-red-dim border border-transparent hover:border-red/20 transition-all duration-150"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Sign out
</button>
<p className="mt-3 px-2 text-[0.62rem] text-text3 leading-relaxed">
Node.js - Docker - Nginx<br/>
Deployed on AWS EC2
</p>
</div>
</aside>
)
}
+91 -348
View File
@@ -1,383 +1,126 @@
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:wght@300;400;500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } @tailwind base;
@tailwind components;
@tailwind utilities;
/* ---- CSS variables ---- */
:root { :root {
--bg: #0a0b0f; --bg: #0a0b0f;
--surface: #111318; --surface: #111318;
--surface2: #16181f; --surface2: #16181f;
--border: #1e2028; --border: #1e2028;
--accent: #6366f1; --accent: #6366f1;
--accent-dim: rgba(99,102,241,0.12); --accent-dim: rgba(99,102,241,0.12);
--green: #22c55e; --green: #22c55e;
--green-dim: rgba(34,197,94,0.12); --green-dim: rgba(34,197,94,0.12);
--red: #ef4444; --red: #ef4444;
--red-dim: rgba(239,68,68,0.12); --red-dim: rgba(239,68,68,0.12);
--text: #f1f5f9; --text: #f1f5f9;
--text-2: #94a3b8; --text-2: #94a3b8;
--text-3: #3d4257; --text-3: #3d4257;
} }
/* ---- Base resets ---- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html { scroll-behavior: smooth; }
body { body {
font-family: 'DM Sans', sans-serif; font-family: 'DM Sans', sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
overflow: hidden; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
/* LAYOUT */ /* ---- Scrollbar ---- */
.app { ::-webkit-scrollbar { width: 6px; }
display: grid; ::-webkit-scrollbar-track { background: var(--bg); }
grid-template-columns: 300px 1fr; ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
min-height: 100vh; ::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
/* ---- Selection ---- */
::selection {
background: rgba(99,102,241,0.3);
color: #fff;
} }
/* SIDEBAR */ /* ---- Focus ring ---- */
.sidebar { :focus-visible {
background: var(--surface); outline: 2px solid var(--accent);
border-right: 1px solid var(--border); outline-offset: 2px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 36px 28px;
}
.sidebar-top { display: flex; flex-direction: column; gap: 36px; }
.brand { display: flex; align-items: center; gap: 14px; }
.brand-icon {
width: 44px;
height: 44px;
background: var(--accent);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-weight: 800;
color: white;
flex-shrink: 0;
box-shadow: 0 0 24px rgba(99,102,241,0.35);
font-family: 'Syne', sans-serif;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 1.2rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 0.72rem;
color: var(--text-3);
font-weight: 500;
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.8px;
}
/* STATS */
.stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.stat-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s;
}
.stat-card:hover { border-color: var(--text-3); }
.stat-card.accent { border-color: rgba(99,102,241,0.3); background: var(--accent-dim); }
.stat-card.green { border-color: rgba(34,197,94,0.3); background: var(--green-dim); }
.stat-number {
font-family: 'Syne', sans-serif;
font-size: 1.6rem;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.stat-card.accent .stat-number { color: #818cf8; }
.stat-card.green .stat-number { color: var(--green); }
.stat-label {
font-size: 0.75rem;
color: var(--text-2);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* PROGRESS */
.progress-section { display: flex; flex-direction: column; gap: 10px; }
.progress-header {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--text-2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-bar {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 3px;
transition: width 0.5s ease;
}
.sidebar-footer { padding-top: 20px; border-top: 1px solid var(--border); }
.built-by {
font-size: 0.7rem;
color: var(--text-3);
font-weight: 500;
line-height: 1.8;
letter-spacing: 0.3px;
}
/* MAIN */
.main {
display: flex;
flex-direction: column;
padding: 40px 48px;
overflow-y: auto;
max-height: 100vh;
}
.main-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.main-header h2 {
font-family: 'Syne', sans-serif;
font-size: 1.8rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.8px;
}
.main-sub {
font-size: 0.85rem;
color: var(--text-3);
margin-top: 4px;
font-weight: 500;
}
/* INPUT */
.input-area {
display: flex;
gap: 12px;
margin-bottom: 36px;
}
input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 20px;
font-size: 0.95rem;
font-family: 'DM Sans', sans-serif;
color: var(--text);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
input::placeholder { color: var(--text-3); }
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99,102,241,0.12);
}
.btn-add {
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
padding: 14px 28px;
font-size: 0.9rem;
font-weight: 700;
font-family: 'DM Sans', sans-serif;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
letter-spacing: 0.2px;
}
.btn-add:hover { background: #4f46e5; transform: translateY(-1px); box-shadow: 0 8px 24px rgba(99,102,241,0.4); }
.btn-add:active { transform: translateY(0); }
.btn-add:disabled { opacity: 0.6; cursor: not-allowed; }
/* TASK GROUPS */
.task-section { display: flex; flex-direction: column; gap: 28px; }
.task-group { display: flex; flex-direction: column; gap: 8px; }
.group-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
padding-left: 4px;
}
.done-label { color: var(--green); }
/* TASK ITEMS */
.task-item {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
transition: all 0.15s;
animation: slideIn 0.2s ease;
} }
/* ---- Animations ---- */
@keyframes slideIn { @keyframes slideIn {
from { opacity: 0; transform: translateY(6px); } from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.task-item:hover { @keyframes fadeUp {
border-color: var(--accent); from { opacity: 0; transform: translateY(16px); }
background: var(--surface2); to { opacity: 1; transform: translateY(0); }
transform: translateX(3px);
} }
.task-item.completed { @keyframes fadeIn {
background: var(--green-dim); from { opacity: 0; }
border-color: rgba(34,197,94,0.25); to { opacity: 1; }
} }
.task-item.completed:hover { @keyframes shimmer {
border-color: var(--green); 0% { background-position: -200% 0; }
transform: translateX(3px); 100% { background-position: 200% 0; }
} }
/* CHECK BUTTON */ @keyframes glowPulse {
.check-btn { 0%, 100% { box-shadow: 0 0 24px rgba(99,102,241,0.35); }
width: 24px; 50% { box-shadow: 0 0 40px rgba(99,102,241,0.55); }
height: 24px;
border-radius: 6px;
border: 2px solid var(--text-3);
background: transparent;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
font-size: 0;
} }
.check-btn:hover { /* ---- Utility classes ---- */
border-color: var(--green); .animate-slide-in { animation: slideIn 0.2s ease; }
background: var(--green-dim); .animate-fade-up { animation: fadeUp 0.4s ease; }
.animate-fade-in { animation: fadeIn 0.3s ease; }
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.10s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.20s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.30s; }
.stagger-7 { animation-delay: 0.35s; }
/* ---- Progress bar ---- */
.progress-fill {
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
} }
.check-btn.checked { /* ---- Gradient text ---- */
background: var(--green); .gradient-text {
border-color: var(--green); background: linear-gradient(135deg, #818cf8 0%, #6366f1 40%, #22c55e 100%);
color: white; -webkit-background-clip: text;
font-size: 0.75rem; -webkit-text-fill-color: transparent;
font-weight: 800; background-clip: text;
} }
/* TASK TITLE */ /* ---- Noise overlay ---- */
.task-title { .noise-overlay::before {
flex: 1; content: '';
font-size: 0.95rem; position: absolute;
color: var(--text); inset: 0;
font-weight: 500; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
line-height: 1.4; pointer-events: none;
z-index: 0;
} }
.completed .task-title { /* ---- Glow pulse ---- */
text-decoration: line-through; .glow-pulse {
text-decoration-color: var(--green); box-shadow: 0 0 24px rgba(99,102,241,0.35);
text-decoration-thickness: 2px; animation: glowPulse 3s ease-in-out infinite;
color: var(--text-2);
} }
/* DELETE BUTTON */
.delete-btn {
background: var(--red-dim);
border: 1px solid rgba(239,68,68,0.3);
color: var(--red);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
padding: 6px 14px;
border-radius: 7px;
transition: all 0.15s;
font-family: 'DM Sans', sans-serif;
flex-shrink: 0;
letter-spacing: 0.2px;
}
.delete-btn:hover {
background: var(--red);
border-color: var(--red);
color: white;
transform: scale(1.05);
}
/* EMPTY STATE */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 12px;
}
.empty-icon { font-size: 3rem; }
.empty-title {
font-family: 'Syne', sans-serif;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-2);
}
.empty-desc {
font-size: 0.85rem;
color: var(--text-3);
font-weight: 500;
}
.empty { color: var(--text-3); font-size: 0.9rem; padding: 40px 0; text-align: center; }
+154
View File
@@ -0,0 +1,154 @@
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
const VALUES = [
{ title: 'Simplicity First', description: 'We believe powerful tools do not have to be complicated. Every feature is designed to reduce friction, not add it.', icon: '✦' },
{ title: 'Built for Reliability', description: 'Downtime is not an option. Our infrastructure is designed for high availability so your team can always access their work.', icon: '⬡' },
{ title: 'Teams Over Individuals', description: 'The best work happens when teams are aligned. We build every feature with collaboration at the center.', icon: '◈' },
{ title: 'Transparency', description: 'No hidden fees, no surprise changes. We communicate openly about our product, pricing, and roadmap.', icon: '◇' },
{ title: 'Continuous Improvement', description: 'We ship improvements every week. Our changelog is public and our users drive our roadmap.', icon: '⟳' },
{ title: 'Security by Default', description: 'Security is not a feature -- it is a foundation. Every layer of our platform is built with security as a first principle.', icon: '⬟' },
]
const STATS = [
{ value: '10,000+', label: 'Tasks managed' },
{ value: '500+', label: 'Active workspaces' },
{ value: '99.9%', label: 'Uptime SLA' },
{ value: '< 200ms', label: 'API response time' },
]
export default function About() {
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<a href="/#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</a>
<Link to="/pricing" className="text-sm text-text2 hover:text-text1 transition-colors">Pricing</Link>
<Link to="/about" className="text-sm text-accent font-600 transition-colors">About</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="relative overflow-hidden py-24 border-b border-border">
<div className="absolute inset-0 bg-[linear-gradient(rgba(99,102,241,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(99,102,241,0.04)_1px,transparent_1px)] bg-[size:60px_60px]" />
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-accent/8 rounded-full blur-[100px] translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-4">About TaskFlow</p>
<h1 className="font-display font-800 text-4xl md:text-5xl text-text1 tracking-tight leading-tight mb-6">
Built for Modern Productivity
</h1>
<p className="text-base text-text2 leading-relaxed max-w-2xl mx-auto">
Our mission is to help teams simplify workflows, improve collaboration, and scale productivity using modern cloud-powered tools designed for efficiency, reliability, and operational excellence.
</p>
</div>
</section>
{/* Stats */}
<section className="py-16 border-b border-border">
<div className="max-w-4xl mx-auto px-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{STATS.map((s, i) => (
<div key={i} className="text-center animate-fade-up" style={{ animationDelay: `${i * 0.1}s` }}>
<p className="font-display font-800 text-3xl text-accent mb-1">{s.value}</p>
<p className="text-xs text-text2 uppercase tracking-wider">{s.label}</p>
</div>
))}
</div>
</div>
</section>
{/* Mission */}
<section className="py-16 border-b border-border">
<div className="max-w-4xl mx-auto px-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div>
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-3">Our Mission</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-5">
Productivity tools should work as hard as you do
</h2>
<p className="text-sm text-text2 leading-relaxed mb-4">
TaskFlow was created to solve a real problem -- teams wasting time switching between tools, losing track of work, and struggling to stay aligned as they grow.
</p>
<p className="text-sm text-text2 leading-relaxed mb-4">
We built a platform that brings tasks, projects, team collaboration, and analytics into one unified workspace. No integrations required. No context switching. Just focused, efficient work.
</p>
<p className="text-sm text-text2 leading-relaxed">
TaskFlow is designed to scale with you -- from a solo developer managing personal projects to a growing team coordinating complex operations across multiple workspaces.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{[
{ label: 'Founded', value: '2024' },
{ label: 'Platform', value: 'Cloud-native' },
{ label: 'Uptime', value: '99.9%' },
{ label: 'Support', value: '24/7' },
].map((item, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 text-center hover:bg-surface2 transition-colors">
<p className="font-display font-800 text-xl text-text1 mb-1">{item.value}</p>
<p className="text-xs text-text3 uppercase tracking-wider">{item.label}</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Values */}
<section className="py-16 border-b border-border">
<div className="max-w-5xl mx-auto px-6">
<div className="text-center mb-12">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-3">Our Values</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">What we stand for</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{VALUES.map((v, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200 animate-fade-up" style={{ animationDelay: `${i * 0.07}s` }}>
<div className="w-10 h-10 rounded-xl bg-accent-dim border border-accent/20 flex items-center justify-center text-accent text-lg mb-4">
{v.icon}
</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{v.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{v.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Join teams already using TaskFlow</h2>
<p className="text-sm text-text2 mb-8">Start organizing your work and collaborating with your team from one powerful workspace.</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/register" className="px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Free
</Link>
<Link to="/contact" className="px-8 py-3.5 bg-surface hover:bg-surface2 text-text1 font-600 rounded-xl border border-border text-sm transition-all">
Contact Us
</Link>
</div>
</div>
</section>
<Footer />
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
function getToken() { return localStorage.getItem('token') }
function timeAgo(date) {
const seconds = Math.floor((new Date() - new Date(date)) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
return `${Math.floor(seconds / 86400)}d ago`
}
function ActivityIcon({ type }) {
if (type === 'completed') return (
<div className="w-8 h-8 rounded-full bg-green-dim border border-green/30 flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</div>
)
if (type === 'created') return (
<div className="w-8 h-8 rounded-full bg-accent-dim border border-accent/30 flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
)
if (type === 'deleted') return (
<div className="w-8 h-8 rounded-full bg-red-dim border border-red/30 flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
</div>
)
return (
<div className="w-8 h-8 rounded-full bg-surface2 border border-border flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2"><circle cx="12" cy="12" r="10"/></svg>
</div>
)
}
export default function Activity() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/tasks', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
.then(d => setTasks(Array.isArray(d) ? d : []))
.catch(() => setTasks([]))
.finally(() => setLoading(false))
}, [])
// Build activity feed from tasks
const activities = tasks.flatMap(task => {
const events = []
events.push({ id: `${task.id}-created`, type: 'created', title: task.title, date: task.created_at })
if (task.completed) {
events.push({ id: `${task.id}-completed`, type: 'completed', title: task.title, date: task.created_at })
}
return events
}).sort((a, b) => new Date(b.date) - new Date(a.date))
const ACTION_TEXT = {
created: 'created task',
completed: 'completed task',
deleted: 'deleted task',
updated: 'updated task',
}
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-2xl mx-auto px-8 py-10">
<div className="mb-8">
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Activity</h1>
<p className="text-sm text-text2 mt-1">A log of everything that has happened in your workspace.</p>
</div>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && activities.length === 0 && (
<div className="flex flex-col items-center py-20 gap-3 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface2 border border-border flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3d4257" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</div>
<p className="font-display font-700 text-text2">No activity yet</p>
<p className="text-sm text-text3">Start creating and completing tasks to see your activity here.</p>
</div>
)}
{!loading && activities.length > 0 && (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-px bg-border" />
<div className="flex flex-col gap-1">
{activities.map((activity, i) => (
<div key={activity.id} className="flex items-start gap-4 pl-0 animate-fade-up" style={{ animationDelay: `${i * 0.03}s` }}>
<div className="relative z-10 flex-shrink-0">
<ActivityIcon type={activity.type} />
</div>
<div className="flex-1 bg-surface border border-border rounded-xl px-4 py-3 mb-3 hover:bg-surface2 transition-colors">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-text3 mb-0.5 uppercase tracking-wider font-600">
{ACTION_TEXT[activity.type] || activity.type}
</p>
<p className="text-sm text-text1 font-500 leading-snug">{activity.title}</p>
</div>
<span className="text-[0.65rem] text-text3 flex-shrink-0 mt-0.5">{timeAgo(activity.date)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</main>
</div>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
} from 'recharts'
function getToken() { return localStorage.getItem('token') }
const CHART_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
function StatCard({ label, value, sub, accent, green }) {
return (
<div className={`rounded-xl border p-5 ${accent ? 'bg-accent-dim border-accent/25' : green ? 'bg-green-dim border-green/25' : 'bg-surface border-border'}`}>
<p className={`font-display font-800 text-3xl leading-none mb-1 ${accent ? 'text-[#818cf8]' : green ? 'text-green' : 'text-text1'}`}>{value}</p>
<p className="text-xs font-600 text-text2 uppercase tracking-wider">{label}</p>
{sub && <p className="text-xs text-text3 mt-1">{sub}</p>}
</div>
)
}
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="bg-surface2 border border-border rounded-lg px-3 py-2 text-xs shadow-lg">
<p className="text-text2 mb-1">{label}</p>
{payload.map((p, i) => (
<p key={i} style={{ color: p.color }} className="font-600">{p.name}: {p.value}</p>
))}
</div>
)
}
export default function Analytics() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/tasks', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
.then(d => setTasks(Array.isArray(d) ? d : []))
.catch(() => setTasks([]))
.finally(() => setLoading(false))
}, [])
const total = tasks.length
const completed = tasks.filter(t => t.completed).length
const active = tasks.filter(t => !t.completed).length
const rate = total > 0 ? Math.round((completed / total) * 100) : 0
// Tasks created per day (last 7 days)
const last7 = [...Array(7)].map((_, i) => {
const d = new Date()
d.setDate(d.getDate() - (6 - i))
const key = d.toDateString()
const label = d.toLocaleDateString('en-US', { weekday: 'short' })
const created = tasks.filter(t => new Date(t.created_at).toDateString() === key).length
const done = tasks.filter(t => t.completed && new Date(t.created_at).toDateString() === key).length
return { day: label, Created: created, Completed: done }
})
// Pie chart data
const pieData = [
{ name: 'Completed', value: completed || 0 },
{ name: 'Active', value: active || 0 },
]
// Task creation trend (last 4 weeks)
const weeklyData = [...Array(4)].map((_, i) => {
const weekStart = new Date()
weekStart.setDate(weekStart.getDate() - (3 - i) * 7 - weekStart.getDay())
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
const count = tasks.filter(t => {
const d = new Date(t.created_at)
return d >= weekStart && d <= weekEnd
}).length
return { week: `W${i + 1}`, Tasks: count }
})
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-10">
<div className="mb-8">
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Analytics</h1>
<p className="text-sm text-text2 mt-1">Track your productivity and task completion over time.</p>
</div>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && (
<>
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Tasks" value={total} />
<StatCard label="Completed" value={completed} green />
<StatCard label="Active" value={active} accent />
<StatCard label="Completion Rate" value={`${rate}%`} sub={rate >= 50 ? 'Great work!' : 'Keep going!'} />
</div>
{/* Charts row 1 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
{/* Bar chart - daily activity */}
<div className="lg:col-span-2 bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-4">Daily Activity (Last 7 Days)</h3>
{total === 0 ? (
<div className="flex items-center justify-center h-40 text-sm text-text3">No task data yet</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={last7} barSize={12} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2028" vertical={false} />
<XAxis dataKey="day" tick={{ fill: '#94a3b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
<Bar dataKey="Created" fill="#6366f1" radius={[4, 4, 0, 0]} />
<Bar dataKey="Completed" fill="#22c55e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie chart - completion breakdown */}
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-4">Task Breakdown</h3>
{total === 0 ? (
<div className="flex items-center justify-center h-40 text-sm text-text3">No task data yet</div>
) : (
<>
<ResponsiveContainer width="100%" height={140}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={40} outerRadius={65} paddingAngle={3} dataKey="value">
{pieData.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-2 mt-2">
{pieData.map((item, i) => (
<div key={item.name} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ background: CHART_COLORS[i] }} />
<span className="text-text2">{item.name}</span>
</div>
<span className="font-600 text-text1">{item.value}</span>
</div>
))}
</div>
</>
)}
</div>
</div>
{/* Line chart - weekly trend */}
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-4">Weekly Task Creation Trend</h3>
{total === 0 ? (
<div className="flex items-center justify-center h-40 text-sm text-text3">No task data yet</div>
) : (
<ResponsiveContainer width="100%" height={160}>
<LineChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2028" vertical={false} />
<XAxis dataKey="week" tick={{ fill: '#94a3b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="Tasks" stroke="#6366f1" strokeWidth={2.5} dot={{ fill: '#6366f1', r: 4 }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</>
)}
</div>
</main>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
function getToken() { return localStorage.getItem('token') }
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
export default function Calendar() {
const [tasks, setTasks] = useState([])
const [today] = useState(new Date())
const [current, setCurrent] = useState(new Date())
useEffect(() => {
fetch('/api/tasks', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
.then(d => setTasks(Array.isArray(d) ? d : []))
.catch(() => setTasks([]))
}, [])
const year = current.getFullYear()
const month = current.getMonth()
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const prevMonth = () => setCurrent(new Date(year, month - 1, 1))
const nextMonth = () => setCurrent(new Date(year, month + 1, 1))
// Group tasks by due_date date string
const tasksByDate = tasks.reduce((acc, task) => {
if (!task.due_date) return acc
const key = new Date(task.due_date).toDateString()
if (!acc[key]) acc[key] = []
acc[key].push(task)
return acc
}, {})
// Tasks with no due date
const noDueDate = tasks.filter(t => !t.due_date && !t.completed)
const cells = []
for (let i = 0; i < firstDay; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-10">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Calendar</h1>
<p className="text-sm text-text2 mt-1">View your tasks and deadlines by date.</p>
</div>
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-2 rounded-lg bg-surface border border-border text-text2 hover:text-text1 hover:bg-surface2 transition-all">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span className="font-display font-700 text-text1 text-sm px-3 min-w-[140px] text-center">
{MONTHS[month]} {year}
</span>
<button onClick={nextMonth} className="p-2 rounded-lg bg-surface border border-border text-text2 hover:text-text1 hover:bg-surface2 transition-all">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
{/* Calendar grid */}
<div className="bg-surface border border-border rounded-2xl overflow-hidden mb-6">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-border">
{DAYS.map(d => (
<div key={d} className="px-3 py-3 text-center text-[0.7rem] font-700 text-text3 uppercase tracking-wider">
{d}
</div>
))}
</div>
{/* Cells */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="min-h-[100px] border-b border-r border-border bg-surface2/30" />
const dateObj = new Date(year, month, day)
const isToday = dateObj.toDateString() === today.toDateString()
const isPast = dateObj < today && !isToday
const dayTasks = tasksByDate[dateObj.toDateString()] || []
const isLastRow = i >= cells.length - 7
const isLastCol = (i + 1) % 7 === 0
return (
<div
key={day}
className={`min-h-[100px] p-2 border-border
${!isLastRow ? 'border-b' : ''}
${!isLastCol ? 'border-r' : ''}
${isToday ? 'bg-accent-dim' : isPast ? 'bg-surface2/20' : ''}
transition-colors`}
>
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-700 mb-1
${isToday ? 'bg-accent text-white' : isPast ? 'text-text3' : 'text-text2 hover:bg-surface2'}`}>
{day}
</div>
<div className="flex flex-col gap-1">
{dayTasks.slice(0, 3).map(task => (
<div
key={task.id}
className={`text-[0.6rem] font-500 px-1.5 py-0.5 rounded truncate
${task.completed ? 'bg-green-dim text-green line-through' : 'bg-accent-dim text-accent'}`}
>
{task.title}
</div>
))}
{dayTasks.length > 3 && (
<div className="text-[0.6rem] text-text3 px-1">+{dayTasks.length - 3} more</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Unscheduled tasks */}
{noDueDate.length > 0 && (
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="text-xs font-700 text-text3 uppercase tracking-wider mb-3">Unscheduled Tasks</h3>
<div className="flex flex-col gap-2">
{noDueDate.map(task => (
<div key={task.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface2 rounded-lg border border-border">
<div className="w-1.5 h-1.5 rounded-full bg-text3 flex-shrink-0" />
<span className="text-sm text-text2">{task.title}</span>
</div>
))}
</div>
</div>
)}
</div>
</main>
</div>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
export default function Contact() {
const [form, setForm] = useState({ name: '', email: '', subject: '', message: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const handleSubmit = e => {
e.preventDefault()
setSending(true)
// Simulate sending
setTimeout(() => {
setSending(false)
setSent(true)
}, 1200)
}
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<a href="/#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</a>
<Link to="/pricing" className="text-sm text-text2 hover:text-text1 transition-colors">Pricing</Link>
<Link to="/about" className="text-sm text-text2 hover:text-text1 transition-colors">About</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="py-20 border-b border-border text-center">
<div className="max-w-2xl mx-auto px-6">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-3">Contact</p>
<h1 className="font-display font-800 text-4xl text-text1 tracking-tight mb-4">Get in Touch</h1>
<p className="text-sm text-text2 leading-relaxed">
Have a question, feedback, or want to discuss how TaskFlow can work for your team? We would love to hear from you.
</p>
</div>
</section>
{/* Content */}
<section className="py-16 flex-1">
<div className="max-w-5xl mx-auto px-6">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-10">
{/* Contact info */}
<div className="lg:col-span-2 flex flex-col gap-5">
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-4">Contact Information</h3>
<div className="flex flex-col gap-4">
<a
href="mailto:elizabethik135@gmail.com"
className="flex items-center gap-3 text-sm text-text2 hover:text-accent transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-accent-dim border border-accent/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:border-accent transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent group-hover:text-white">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
elizabethik135@gmail.com
</a>
<a
href="https://www.linkedin.com/in/ikechukwu-elizabeth"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm text-text2 hover:text-accent transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-accent-dim border border-accent/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:border-accent transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent group-hover:text-white">
<path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2z"/>
<circle cx="4" cy="4" r="2"/>
</svg>
</div>
LinkedIn Profile
</a>
<a
href="https://github.com/elizabeth-ikechukwu/scalable-task-platform"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-sm text-text2 hover:text-accent transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-accent-dim border border-accent/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:border-accent transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent group-hover:text-white">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.202 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
</div>
GitHub Repository
</a>
</div>
</div>
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-3">Response Time</h3>
<p className="text-xs text-text2 leading-relaxed">
We typically respond within 24 hours on business days. For urgent issues, email is the fastest way to reach us.
</p>
</div>
<div className="bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-sm mb-3">Open Source</h3>
<p className="text-xs text-text2 leading-relaxed mb-3">
TaskFlow is open source. Found a bug or want to contribute? Open an issue or pull request on GitHub.
</p>
<a
href="https://github.com/elizabeth-ikechukwu/scalable-task-platform"
target="_blank"
rel="noopener noreferrer"
className="text-xs font-600 text-accent hover:underline"
>
View on GitHub →
</a>
</div>
</div>
{/* Contact form */}
<div className="lg:col-span-3">
<div className="bg-surface border border-border rounded-2xl p-6">
{sent ? (
<div className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-green-dim border border-green/30 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<h3 className="font-display font-700 text-text1 text-lg">Message Sent!</h3>
<p className="text-sm text-text2 max-w-xs">Thank you for reaching out. We will get back to you within 24 hours.</p>
<button
onClick={() => { setSent(false); setForm({ name: '', email: '', subject: '', message: '' }) }}
className="mt-2 text-xs font-600 text-accent hover:underline"
>
Send another message
</button>
</div>
) : (
<>
<h2 className="font-display font-700 text-text1 text-lg mb-5">Send a Message</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Full Name</label>
<input
value={form.name}
onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="Your name"
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Email Address</label>
<input
type="email"
value={form.email}
onChange={e => setForm(p => ({ ...p, email: e.target.value }))}
placeholder="you@example.com"
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Subject</label>
<select
value={form.subject}
onChange={e => setForm(p => ({ ...p, subject: e.target.value }))}
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 outline-none focus:border-accent transition-all"
>
<option value="">Select a subject</option>
<option value="general">General Inquiry</option>
<option value="support">Technical Support</option>
<option value="billing">Billing Question</option>
<option value="enterprise">Enterprise Sales</option>
<option value="feedback">Product Feedback</option>
</select>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Message</label>
<textarea
value={form.message}
onChange={e => setForm(p => ({ ...p, message: e.target.value }))}
placeholder="Tell us how we can help..."
rows={5}
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all resize-none"
/>
</div>
<button
type="submit"
disabled={sending}
className="w-full py-3 bg-accent hover:bg-accent-hover disabled:opacity-60 text-white font-700 rounded-lg text-sm transition-all shadow-accent hover:-translate-y-0.5 flex items-center justify-center gap-2"
>
{sending ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Sending...
</>
) : 'Send Message'}
</button>
</form>
</>
)}
</div>
</div>
</div>
</div>
</section>
<Footer />
</div>
)
}
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import { EmptyTasks } from '../components/EmptyState'
const API = '/api'
/* ---- Sub-components ---- */
function StatCard({ value, label, accent, green }) {
return (
<div className={`
rounded-xl border p-5 flex flex-col gap-1 transition-colors duration-150
${accent ? 'bg-accent-dim border-accent/25' : green ? 'bg-green-dim border-green/25' : 'bg-surface2 border-border hover:border-text3'}
`}>
<span className={`font-display font-800 text-3xl leading-none ${accent ? 'text-[#818cf8]' : green ? 'text-green' : 'text-text1'}`}>
{value}
</span>
<span className="text-xs font-500 text-text2 uppercase tracking-wider">{label}</span>
</div>
)
}
function ProgressBar({ value }) {
return (
<div className="rounded-xl border border-border bg-surface2 p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-700 text-text2 uppercase tracking-wider">Overall Progress</span>
<span className="text-sm font-700 text-accent">{Math.round(value)}%</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent to-green rounded-full progress-fill"
style={{ width: `${value}%` }}
/>
</div>
<p className="mt-2 text-xs text-text3">
{value === 100 ? 'All tasks complete!' : value === 0 ? 'No tasks completed yet' : 'Keep going, you\'re making progress'}
</p>
</div>
)
}
function TaskItem({ task, onToggle, onDelete }) {
return (
<div className={`
flex items-center gap-3 rounded-xl border px-4 py-3.5 transition-all duration-150 animate-slide-in
${task.completed
? 'bg-green-dim border-green/20 hover:border-green/40'
: 'bg-surface border-border hover:border-accent/40 hover:bg-surface2 hover:translate-x-0.5'
}
`}>
{/* Check button */}
<button
onClick={() => onToggle(task.id)}
title={task.completed ? 'Mark incomplete' : 'Mark complete'}
className={`
w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all duration-150
${task.completed
? 'bg-green border-green'
: 'border-text3 hover:border-green hover:bg-green-dim'
}
`}
>
{task.completed && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2.5">
<polyline points="1.5,6 5,9.5 10.5,2.5"/>
</svg>
)}
</button>
{/* Title */}
<span className={`flex-1 text-sm font-500 leading-snug ${task.completed ? 'line-through text-text3 decoration-green decoration-2' : 'text-text1'}`}>
{task.title}
</span>
{/* Delete button */}
<button
onClick={() => onDelete(task.id)}
className="flex-shrink-0 px-2.5 py-1 rounded-md text-xs font-600 text-red bg-red-dim border border-red/20 hover:bg-red hover:text-white hover:border-red transition-all duration-150"
>
Delete
</button>
</div>
)
}
/* ---- Dashboard page ---- */
export default function Dashboard() {
const [tasks, setTasks] = useState([])
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(true)
const [adding, setAdding] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const fetchTasks = async () => {
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API}/tasks`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const data = await res.json()
setTasks(Array.isArray(data) ? data : [])
} catch {
setTasks([])
} finally {
setLoading(false)
}
}
useEffect(() => { fetchTasks() }, [])
const addTask = async () => {
if (!title.trim()) return
setAdding(true)
try {
const token = localStorage.getItem('token')
await fetch(`${API}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ title }),
})
setTitle('')
fetchTasks()
} finally {
setAdding(false)
}
}
const toggleTask = async id => {
const token = localStorage.getItem('token')
await fetch(`${API}/tasks/${id}`, {
method: 'PATCH',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
fetchTasks()
}
const deleteTask = async id => {
const token = localStorage.getItem('token')
await fetch(`${API}/tasks/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
fetchTasks()
}
const completed = tasks.filter(t => t.completed).length
const remaining = tasks.filter(t => !t.completed).length
const progress = tasks.length > 0 ? (completed / tasks.length) * 100 : 0
const now = new Date()
const hour = now.getHours()
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
{/* Main content - offset by sidebar width */}
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-4xl mx-auto px-8 py-10">
{/* ---- Welcome card ---- */}
<div className="relative rounded-2xl border border-border bg-surface overflow-hidden mb-8 p-7">
{/* Background glow */}
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/8 rounded-full blur-[80px] translate-x-1/3 -translate-y-1/3 pointer-events-none" />
<div className="relative z-10">
<p className="text-xs font-700 text-text3 uppercase tracking-widest mb-1">
{now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight mb-1">
{greeting} <span className="animate-fade-in">&#128075;</span>
</h1>
<p className="text-sm text-text2 font-500">Stay focused, organized, and in control of your work.</p>
<p className="text-xs text-text3 mt-2 max-w-xl leading-relaxed">
Track active projects, manage deadlines, collaborate with your team, and monitor progress from a single dashboard built for productivity at scale.
</p>
</div>
</div>
{/* ---- Stats row ---- */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard value={tasks.length} label="Total Tasks" />
<StatCard value={remaining} label="Remaining" accent />
<StatCard value={completed} label="Completed" green />
<StatCard value={`${Math.round(progress)}%`} label="Progress" />
</div>
{/* ---- Progress bar ---- */}
<div className="mb-8">
<ProgressBar value={progress} />
</div>
{/* ---- Tasks panel ---- */}
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
<div className="px-6 py-5 border-b border-border flex items-center justify-between">
<div>
<h2 className="font-display font-700 text-text1 tracking-tight">My Tasks</h2>
<p className="text-xs text-text3 mt-0.5">Stay focused. Ship things.</p>
</div>
<span className="text-xs font-600 text-text3 bg-surface2 border border-border rounded-full px-3 py-1">
{tasks.length} total
</span>
</div>
<div className="p-6">
{/* Input */}
<div className={`flex gap-3 mb-6 p-1 rounded-xl border transition-all duration-150 ${inputFocused ? 'border-accent bg-surface2 shadow-[0_0_0_3px_rgba(99,102,241,0.1)]' : 'border-border bg-surface2'}`}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTask()}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
placeholder="What needs to be done? Press Enter to add..."
className="flex-1 bg-transparent px-3 py-2.5 text-sm text-text1 placeholder-text3 outline-none"
/>
<button
onClick={addTask}
disabled={adding || !title.trim()}
className="px-5 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-700 rounded-lg transition-all duration-150 whitespace-nowrap flex-shrink-0"
>
{adding ? '...' : '+ Add Task'}
</button>
</div>
{/* Task list */}
{loading && (
<div className="flex flex-col items-center py-16 gap-3">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<p className="text-xs text-text3">Loading your tasks...</p>
</div>
)}
{!loading && tasks.length === 0 && (
<EmptyTasks onAdd={() => document.querySelector('input')?.focus()} />
)}
{!loading && tasks.length > 0 && (
<div className="flex flex-col gap-6">
{/* Active tasks */}
{tasks.filter(t => !t.completed).length > 0 && (
<div>
<p className="text-[0.7rem] font-700 text-accent uppercase tracking-widest mb-2 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent inline-block" />
Active ({tasks.filter(t => !t.completed).length})
</p>
<div className="flex flex-col gap-2">
{tasks.filter(t => !t.completed).map(task => (
<TaskItem key={task.id} task={task} onToggle={toggleTask} onDelete={deleteTask} />
))}
</div>
</div>
)}
{/* Completed tasks */}
{tasks.filter(t => t.completed).length > 0 && (
<div>
<p className="text-[0.7rem] font-700 text-green uppercase tracking-widest mb-2 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green inline-block" />
Completed ({tasks.filter(t => t.completed).length})
</p>
<div className="flex flex-col gap-2">
{tasks.filter(t => t.completed).map(task => (
<TaskItem key={task.id} task={task} onToggle={toggleTask} onDelete={deleteTask} />
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Bottom metadata */}
<p className="mt-6 text-center text-xs text-text3">
Node.js - Express - PostgreSQL on AWS RDS - Docker - Nginx - Terraform
</p>
</div>
</main>
</div>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
import FeatureCard from '../components/FeatureCard'
const FEATURES = [
{
title: 'Task Management',
description: 'Create, assign, and prioritize tasks with due dates, labels, and status tracking. Keep every piece of work accounted for.',
to: '/features/task-management',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
),
color: 'text-accent',
bg: 'bg-accent-dim border-accent/20',
},
{
title: 'Team Collaboration',
description: 'Work alongside your team in shared workspaces. Assign tasks, leave comments, and stay aligned without switching tools.',
to: '/features/team-collaboration',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</svg>
),
color: 'text-green',
bg: 'bg-green-dim border-green/20',
},
{
title: 'Smart Notifications',
description: 'Stay informed with context-aware alerts. Get notified about deadlines, mentions, and updates that actually matter to you.',
to: '/features/notifications',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
),
color: 'text-yellow-400',
bg: 'bg-yellow-400/10 border-yellow-400/20',
},
{
title: 'Project Workspaces',
description: 'Organize tasks into focused project spaces. Each workspace gives your team a dedicated area to plan, execute, and review.',
to: '/features/project-workspaces',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 7a2 2 0 012-2h4l2 3h9a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V7z"/>
</svg>
),
color: 'text-blue-400',
bg: 'bg-blue-400/10 border-blue-400/20',
},
{
title: 'Analytics & Reporting',
description: 'Track velocity, completion rates, and team throughput with visual dashboards. Turn raw activity into actionable insights.',
to: '/features/analytics',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
color: 'text-purple-400',
bg: 'bg-purple-400/10 border-purple-400/20',
},
{
title: 'Secure Access Control',
description: 'JWT-based authentication, role-based permissions, and encrypted credentials. Your data stays private by design.',
to: '/features/security',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
</svg>
),
color: 'text-red-400',
bg: 'bg-red-400/10 border-red-400/20',
},
{
title: 'Scalable Architecture',
description: 'Cloud-native infrastructure designed to grow with your team. Built for reliability, performance, and operational excellence.',
to: '/features/architecture',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
),
color: 'text-accent',
bg: 'bg-accent-dim border-accent/20',
},
]
function DashboardPreview() {
return (
<div className="relative w-full max-w-2xl mx-auto">
<div className="absolute inset-0 bg-accent/10 blur-3xl rounded-3xl scale-95" />
<div className="relative bg-surface border border-border rounded-2xl overflow-hidden shadow-2xl">
<div className="flex items-center gap-2 px-4 py-3 border-b border-border bg-surface2">
<span className="w-2.5 h-2.5 rounded-full bg-red/60" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400/60" />
<span className="w-2.5 h-2.5 rounded-full bg-green/60" />
<span className="ml-3 text-xs text-text3 font-500">app.taskflow.io/dashboard</span>
</div>
<div className="flex h-52">
<div className="w-14 bg-surface2 border-r border-border flex flex-col items-center py-3 gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-7 h-7 rounded-lg ${i === 0 ? 'bg-accent' : 'bg-border'}`} />
))}
</div>
<div className="flex-1 p-4 flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2">
{['24 Tasks', '18 Done', '6 Active'].map((s, i) => (
<div key={i} className="bg-surface2 rounded-lg p-2 border border-border">
<div className="text-xs font-700 text-text1">{s.split(' ')[0]}</div>
<div className="text-[0.6rem] text-text3">{s.split(' ')[1]}</div>
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
{[
{ label: 'Review Q3 product roadmap', done: true },
{ label: 'Design onboarding flow', done: true },
{ label: 'Launch email campaign', done: false },
{ label: 'Prepare team performance review', done: false },
].map((t, i) => (
<div key={i} className="flex items-center gap-2 bg-surface2 rounded-md px-2.5 py-1.5 border border-border">
<div className={`w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0 ${t.done ? 'bg-green border-green' : 'border border-text3'}`}>
{t.done && <svg width="8" height="8" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>}
</div>
<span className={`text-[0.65rem] ${t.done ? 'line-through text-text3' : 'text-text2'}`}>{t.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
export default function Landing() {
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</div>
<nav className="hidden md:flex items-center gap-6">
<a href="#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</a>
<Link to="/pricing" className="text-sm text-text2 hover:text-text1 transition-colors">Pricing</Link>
<Link to="/about" className="text-sm text-text2 hover:text-text1 transition-colors">About</Link>
<Link to="/dashboard" className="text-sm text-text2 hover:text-text1 transition-colors">Dashboard</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="relative overflow-hidden flex-1 flex items-center noise-overlay">
<div className="absolute inset-0 bg-[linear-gradient(rgba(99,102,241,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(99,102,241,0.04)_1px,transparent_1px)] bg-[size:60px_60px]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent/8 rounded-full blur-[120px] pointer-events-none" />
<div className="relative z-10 max-w-6xl mx-auto px-6 py-24 w-full">
<div className="flex flex-col items-center text-center mb-16">
<h1 className="font-display font-800 text-4xl md:text-6xl text-text1 leading-[1.1] tracking-tight mb-6 max-w-3xl animate-fade-up">
Manage Tasks, Teams, and{' '}
<span className="gradient-text">Workflows at Scale</span>
</h1>
<p className="text-base md:text-lg text-text2 leading-relaxed max-w-2xl mb-10 animate-fade-up stagger-2">
A modern task management platform built for individuals, startups, and growing teams to organize projects, automate workflows, track progress, and collaborate in real time -- all from one centralized workspace.
</p>
<div className="flex flex-col sm:flex-row items-center gap-4 animate-fade-up stagger-3">
<Link to="/register"
className="w-full sm:w-auto px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl transition-all shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5 text-sm tracking-wide">
Get Started Free
</Link>
<Link to="/dashboard"
className="w-full sm:w-auto px-8 py-3.5 bg-surface hover:bg-surface2 text-text1 font-600 rounded-xl border border-border hover:border-text3 transition-all text-sm tracking-wide flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/>
</svg>
View Demo
</Link>
</div>
</div>
<div className="animate-fade-up stagger-4">
<DashboardPreview />
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-24 border-t border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-3">Platform Features</p>
<h2 className="font-display font-800 text-3xl md:text-4xl text-text1 tracking-tight mb-4">
Everything your team needs
</h2>
<p className="text-text2 max-w-xl mx-auto text-sm leading-relaxed">
Click any feature to learn more about how it works and what it includes.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{FEATURES.map((f, i) => (
<FeatureCard key={f.title} {...f} index={i} />
))}
</div>
</div>
</section>
{/* CTA banner */}
<section className="py-20 border-t border-border">
<div className="max-w-3xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-3xl text-text1 tracking-tight mb-4">Ready to get organized?</h2>
<p className="text-text2 text-sm leading-relaxed mb-8 max-w-lg mx-auto">
Join thousands of teams managing tasks, projects, and workflows from one powerful workspace.
</p>
<Link to="/register"
className="inline-block px-10 py-4 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl transition-all shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5">
Start for Free
</Link>
</div>
</section>
<Footer />
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
const API = '/api'
export default function Login() {
const navigate = useNavigate()
const [form, setForm] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
const handleSubmit = async e => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) { setError(data.error || 'Login failed'); return }
localStorage.setItem('token', data.token)
navigate('/dashboard')
} catch {
setError('Network error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex">
{/* ---- Left branding panel ---- */}
<div className="hidden lg:flex lg:w-1/2 relative flex-col justify-between p-12 overflow-hidden">
{/* Background layers */}
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 via-bg to-bg" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(99,102,241,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(99,102,241,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-accent/10 rounded-full blur-[100px] -translate-x-1/2 translate-y-1/2" />
{/* Content */}
<div className="relative z-10">
<Link to="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 text-lg tracking-tight">TaskFlow</span>
</Link>
</div>
<div className="relative z-10">
<h2 className="font-display font-800 text-3xl text-text1 leading-tight tracking-tight mb-4">
Access your workspace and continue managing your projects efficiently.
</h2>
<p className="text-text2 text-sm leading-relaxed max-w-sm">
Organized tasks, focused teams, and infrastructure that scales. Everything in one place.
</p>
{/* Trust indicators */}
<div className="mt-10 flex flex-col gap-3">
{[
'PostgreSQL on AWS RDS',
'JWT-secured authentication',
'CI/CD via GitHub Actions',
].map(item => (
<div key={item} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-green-dim border border-green/30 flex items-center justify-center flex-shrink-0">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>
</div>
<span className="text-sm text-text2">{item}</span>
</div>
))}
</div>
</div>
<div className="relative z-10">
<p className="text-xs text-text3">
Built by{' '}
<a href="https://www.linkedin.com/in/ikechukwu-elizabeth" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Elizabeth Ikechukwu
</a>
{' '}- DevOps &amp; Cloud Engineer
</p>
</div>
</div>
{/* ---- Right auth panel ---- */}
<div className="flex-1 flex items-center justify-center px-6 py-16">
<div className="w-full max-w-sm">
{/* Mobile logo */}
<div className="flex items-center gap-2.5 mb-10 lg:hidden">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1">TaskFlow</span>
</div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight mb-1">Welcome back</h1>
<p className="text-sm text-text2 mb-8">Sign in to your workspace to continue.</p>
{error && (
<div className="mb-5 px-4 py-3 rounded-lg bg-red-dim border border-red/30 text-red text-sm flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Email address</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="you@example.com"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-600 text-text2 uppercase tracking-wider">Password</label>
<a href="#" className="text-xs text-accent hover:underline">Forgot password?</a>
</div>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="Enter your password"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-2 py-3 bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white font-700 rounded-lg text-sm transition-all duration-150 shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5 active:translate-y-0"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="mt-6 text-center text-sm text-text2">
Don&apos;t have an account?{' '}
<Link to="/register" className="text-accent hover:underline font-600">Create one free</Link>
</p>
</div>
</div>
</div>
)
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import { EmptyNotifications } from '../components/EmptyState'
function getToken() { return localStorage.getItem('token') }
// Notification types and their styles
const TYPE_CONFIG = {
deadline: {
color: 'text-yellow-400',
bg: 'bg-yellow-400/10 border-yellow-400/20',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
},
completed: {
color: 'text-green',
bg: 'bg-green-dim border-green/20',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
},
mention: {
color: 'text-accent',
bg: 'bg-accent-dim border-accent/20',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-3.92 7.94"/>
</svg>
),
},
system: {
color: 'text-text2',
bg: 'bg-surface2 border-border',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
),
},
}
function timeAgo(date) {
const seconds = Math.floor((new Date() - new Date(date)) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
return `${Math.floor(seconds / 86400)}d ago`
}
export default function Notifications() {
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Build notifications from tasks
fetch('/api/tasks', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
.then(tasks => {
if (!Array.isArray(tasks)) { setNotifications([]); return }
const notifs = []
// System welcome notification
notifs.push({
id: 'system-welcome',
type: 'system',
title: 'Welcome to TaskFlow',
message: 'Your workspace is ready. Start by creating your first task or inviting a team member.',
date: new Date().toISOString(),
read: false,
})
// Completed task notifications
tasks.filter(t => t.completed).forEach(task => {
notifs.push({
id: `completed-${task.id}`,
type: 'completed',
title: 'Task completed',
message: `"${task.title}" was marked as complete.`,
date: task.created_at,
read: false,
})
})
// Active task reminders
tasks.filter(t => !t.completed).slice(0, 2).forEach(task => {
notifs.push({
id: `reminder-${task.id}`,
type: 'deadline',
title: 'Task pending',
message: `"${task.title}" is still in progress. Stay on track!`,
date: task.created_at,
read: true,
})
})
setNotifications(notifs.sort((a, b) => new Date(b.date) - new Date(a.date)))
})
.catch(() => setNotifications([]))
.finally(() => setLoading(false))
}, [])
const markAllRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
}
const markRead = id => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n))
}
const unreadCount = notifications.filter(n => !n.read).length
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-2xl mx-auto px-8 py-10">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight flex items-center gap-3">
Notifications
{unreadCount > 0 && (
<span className="bg-accent text-white text-xs font-700 px-2 py-0.5 rounded-full">
{unreadCount}
</span>
)}
</h1>
<p className="text-sm text-text2 mt-1">Stay updated on your workspace activity.</p>
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs font-600 text-accent hover:underline transition-colors"
>
Mark all as read
</button>
)}
</div>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && notifications.length === 0 && <EmptyNotifications />}
{!loading && notifications.length > 0 && (
<div className="flex flex-col gap-2">
{notifications.map((notif, i) => {
const config = TYPE_CONFIG[notif.type] || TYPE_CONFIG.system
return (
<div
key={notif.id}
onClick={() => markRead(notif.id)}
className={`flex items-start gap-4 p-4 rounded-xl border transition-all duration-150 cursor-pointer animate-fade-up
${notif.read ? 'bg-surface border-border opacity-70 hover:opacity-100' : 'bg-surface border-border hover:bg-surface2'}
`}
style={{ animationDelay: `${i * 0.04}s` }}
>
<div className={`w-8 h-8 rounded-full border flex items-center justify-center flex-shrink-0 ${config.bg} ${config.color}`}>
{config.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="text-sm font-600 text-text1">{notif.title}</p>
{!notif.read && <span className="w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />}
</div>
<p className="text-xs text-text2 leading-relaxed">{notif.message}</p>
</div>
<span className="text-[0.65rem] text-text3 flex-shrink-0">{timeAgo(notif.date)}</span>
</div>
)
})}
</div>
)}
</div>
</main>
</div>
)
}
+289
View File
@@ -0,0 +1,289 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
const PLANS = [
{
name: 'Free',
monthly: 0,
yearly: 0,
description: 'Perfect for individuals and small personal projects.',
cta: 'Get Started Free',
ctaTo: '/register',
highlight: false,
features: [
'Up to 50 tasks',
'1 workspace',
'Basic task management',
'Email notifications',
'Mobile access',
'Community support',
],
missing: [
'Team collaboration',
'Analytics & reporting',
'Priority support',
'Custom integrations',
],
},
{
name: 'Pro',
monthly: 12,
yearly: 9,
description: 'For professionals and growing teams who need more power.',
cta: 'Start Pro Trial',
ctaTo: '/register',
highlight: true,
badge: 'Most Popular',
features: [
'Unlimited tasks',
'Up to 5 workspaces',
'Team collaboration',
'Advanced analytics',
'Priority notifications',
'File attachments',
'Activity feed',
'Priority email support',
],
missing: [
'Custom integrations',
'SSO authentication',
],
},
{
name: 'Enterprise',
monthly: 49,
yearly: 39,
description: 'For large teams and organizations that need full control.',
cta: 'Contact Sales',
ctaTo: '/contact',
highlight: false,
features: [
'Unlimited everything',
'Unlimited workspaces',
'SSO authentication',
'Custom integrations',
'Advanced security',
'Role-based permissions',
'SLA guarantee',
'Dedicated support',
'Custom onboarding',
'Audit logs',
],
missing: [],
},
]
const FAQS = [
{
q: 'Can I switch plans at any time?',
a: 'Yes. You can upgrade or downgrade your plan at any time. Changes take effect immediately and billing is prorated.',
},
{
q: 'Is there a free trial for paid plans?',
a: 'Yes. Pro and Enterprise plans come with a 14-day free trial. No credit card required to start.',
},
{
q: 'What happens to my data if I downgrade?',
a: 'Your data is safe. If you exceed the limits of a lower plan, you will be prompted to upgrade or archive excess data.',
},
{
q: 'Do you offer discounts for nonprofits or students?',
a: 'Yes. We offer 50% off Pro plans for verified nonprofits and students. Contact us to apply.',
},
{
q: 'How does team billing work?',
a: 'Plans are per workspace. You pay for one plan per workspace regardless of how many members are in it.',
},
]
function CheckIcon({ positive = true }) {
if (positive) return (
<div className="w-4 h-4 rounded-full bg-green-dim border border-green/30 flex items-center justify-center flex-shrink-0">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>
</div>
)
return (
<div className="w-4 h-4 rounded-full bg-surface2 border border-border flex items-center justify-center flex-shrink-0">
<svg width="7" height="7" viewBox="0 0 12 12" fill="none" stroke="#3d4257" strokeWidth="2.5"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</div>
)
}
export default function Pricing() {
const [yearly, setYearly] = useState(false)
const [openFaq, setOpenFaq] = useState(null)
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<a href="/#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</a>
<Link to="/pricing" className="text-sm text-accent font-600 transition-colors">Pricing</Link>
<Link to="/about" className="text-sm text-text2 hover:text-text1 transition-colors">About</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="py-20 border-b border-border text-center">
<div className="max-w-3xl mx-auto px-6">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-3">Pricing</p>
<h1 className="font-display font-800 text-4xl md:text-5xl text-text1 tracking-tight mb-5">
Simple, Transparent Pricing
</h1>
<p className="text-base text-text2 leading-relaxed mb-10">
Choose a plan that scales with your workflow -- whether you are managing personal tasks, startup operations, or enterprise-scale collaboration.
</p>
{/* Toggle */}
<div className="inline-flex items-center gap-3 bg-surface2 border border-border rounded-xl p-1.5">
<button
onClick={() => setYearly(false)}
className={`px-5 py-2 rounded-lg text-sm font-600 transition-all ${!yearly ? 'bg-accent text-white shadow-accent' : 'text-text2 hover:text-text1'}`}
>
Monthly
</button>
<button
onClick={() => setYearly(true)}
className={`px-5 py-2 rounded-lg text-sm font-600 transition-all flex items-center gap-2 ${yearly ? 'bg-accent text-white shadow-accent' : 'text-text2 hover:text-text1'}`}
>
Yearly
<span className={`text-[0.6rem] font-700 px-1.5 py-0.5 rounded-full ${yearly ? 'bg-white/20 text-white' : 'bg-green-dim text-green border border-green/20'}`}>
Save 25%
</span>
</button>
</div>
</div>
</section>
{/* Pricing cards */}
<section className="py-16 border-b border-border">
<div className="max-w-5xl mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{PLANS.map((plan, i) => (
<div
key={plan.name}
className={`rounded-2xl border p-6 flex flex-col transition-all duration-200 animate-fade-up
${plan.highlight
? 'bg-accent-dim border-accent/30 shadow-xl shadow-accent/10 scale-[1.02]'
: 'bg-surface border-border hover:border-text3'
}`}
style={{ animationDelay: `${i * 0.1}s` }}
>
{plan.badge && (
<div className="inline-flex items-center gap-1.5 bg-accent text-white text-[0.65rem] font-700 px-2.5 py-1 rounded-full mb-4 w-fit">
<span className="w-1 h-1 rounded-full bg-white/60 animate-pulse" />
{plan.badge}
</div>
)}
<h2 className="font-display font-800 text-text1 text-xl mb-1">{plan.name}</h2>
<p className="text-xs text-text2 mb-5 leading-relaxed">{plan.description}</p>
<div className="mb-6">
<span className="font-display font-800 text-4xl text-text1">
${yearly ? plan.yearly : plan.monthly}
</span>
<span className="text-text3 text-sm ml-1">/month</span>
{yearly && plan.monthly > 0 && (
<p className="text-xs text-green mt-1">Billed annually -- saves ${(plan.monthly - plan.yearly) * 12}/yr</p>
)}
</div>
<Link
to={plan.ctaTo}
className={`w-full py-3 rounded-xl text-sm font-700 text-center transition-all duration-150 mb-6
${plan.highlight
? 'bg-accent hover:bg-accent-hover text-white shadow-accent hover:-translate-y-0.5'
: 'bg-surface2 hover:bg-border text-text1 border border-border hover:border-text3'
}`}
>
{plan.cta}
</Link>
<div className="flex flex-col gap-2.5">
{plan.features.map(f => (
<div key={f} className="flex items-center gap-2.5">
<CheckIcon positive={true} />
<span className="text-xs text-text2">{f}</span>
</div>
))}
{plan.missing.map(f => (
<div key={f} className="flex items-center gap-2.5">
<CheckIcon positive={false} />
<span className="text-xs text-text3 line-through">{f}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-16 border-b border-border">
<div className="max-w-2xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Frequently Asked Questions</h2>
<p className="text-sm text-text2">Everything you need to know about our pricing and plans.</p>
</div>
<div className="flex flex-col gap-2">
{FAQS.map((faq, i) => (
<div key={i} className="bg-surface border border-border rounded-xl overflow-hidden">
<button
onClick={() => setOpenFaq(openFaq === i ? null : i)}
className="w-full px-5 py-4 flex items-center justify-between text-left hover:bg-surface2 transition-colors"
>
<span className="text-sm font-600 text-text1">{faq.q}</span>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
className={`flex-shrink-0 text-text3 transition-transform duration-200 ${openFaq === i ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{openFaq === i && (
<div className="px-5 pb-4 text-sm text-text2 leading-relaxed border-t border-border pt-3">
{faq.a}
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Start for free today</h2>
<p className="text-sm text-text2 mb-8">No credit card required. Upgrade when you are ready.</p>
<Link to="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Free
</Link>
</div>
</section>
<Footer />
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import { EmptyProjects } from '../components/EmptyState'
const API = '/api'
function getToken() { return localStorage.getItem('token') }
function headers() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }
}
const COLORS = [
'bg-accent border-accent/30',
'bg-green border-green/30',
'bg-blue-500 border-blue-500/30',
'bg-purple-500 border-purple-500/30',
'bg-yellow-500 border-yellow-500/30',
'bg-red border-red/30',
]
export default function Projects() {
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const [showing, setShowing] = useState(false)
const [form, setForm] = useState({ name: '', description: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const fetchProjects = async () => {
try {
const res = await fetch(`${API}/projects`, { headers: { Authorization: `Bearer ${getToken()}` } })
const data = await res.json()
setProjects(Array.isArray(data) ? data : [])
} catch {
setProjects([])
} finally {
setLoading(false)
}
}
useEffect(() => { fetchProjects() }, [])
const handleCreate = async e => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError('')
try {
const res = await fetch(`${API}/projects`, { method: 'POST', headers: headers(), body: JSON.stringify(form) })
const data = await res.json()
if (!res.ok) { setError(data.error || 'Failed to create project'); return }
setForm({ name: '', description: '' })
setShowing(false)
fetchProjects()
} catch {
setError('Network error. Please try again.')
} finally {
setSaving(false)
}
}
const handleDelete = async id => {
if (!window.confirm('Delete this project?')) return
await fetch(`${API}/projects/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${getToken()}` } })
fetchProjects()
}
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Projects</h1>
<p className="text-sm text-text2 mt-1">Organize your work into focused project spaces.</p>
</div>
<button
onClick={() => setShowing(true)}
className="px-4 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-700 rounded-lg transition-all duration-150 shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5 flex items-center gap-2"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Project
</button>
</div>
{/* Create modal */}
{showing && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-2xl w-full max-w-md p-6 animate-fade-up">
<h2 className="font-display font-700 text-text1 text-lg mb-5">Create New Project</h2>
{error && (
<div className="mb-4 px-3 py-2.5 rounded-lg bg-red-dim border border-red/30 text-red text-sm">{error}</div>
)}
<form onSubmit={handleCreate} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Project Name</label>
<input
value={form.name}
onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="e.g. AWS Infrastructure Setup"
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Description</label>
<textarea
value={form.description}
onChange={e => setForm(p => ({ ...p, description: e.target.value }))}
placeholder="What is this project about?"
rows={3}
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all resize-none"
/>
</div>
<div className="flex gap-3 mt-2">
<button type="button" onClick={() => { setShowing(false); setError('') }}
className="flex-1 py-2.5 bg-surface2 hover:bg-border text-text2 text-sm font-600 rounded-lg border border-border transition-all">
Cancel
</button>
<button type="submit" disabled={saving}
className="flex-1 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-700 rounded-lg transition-all shadow-accent">
{saving ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Projects grid */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && projects.length === 0 && (
<EmptyProjects onAdd={() => setShowing(true)} />
)}
{!loading && projects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{projects.map((project, i) => (
<div key={project.id} className="bg-surface border border-border rounded-xl p-5 hover:border-accent/40 hover:bg-surface2 transition-all duration-150 group animate-fade-up">
<div className="flex items-start justify-between mb-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white font-700 text-sm flex-shrink-0 ${COLORS[i % COLORS.length].split(' ')[0]}`}>
{project.name.charAt(0).toUpperCase()}
</div>
<button
onClick={() => handleDelete(project.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-text3 hover:text-red hover:bg-red-dim transition-all"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
<h3 className="font-display font-700 text-text1 text-sm mb-1">{project.name}</h3>
<p className="text-xs text-text2 leading-relaxed mb-4">{project.description || 'No description provided.'}</p>
<div className="flex items-center justify-between">
<span className="text-[0.65rem] text-text3">
{new Date(project.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="text-[0.65rem] font-600 text-accent bg-accent-dim border border-accent/20 px-2 py-0.5 rounded-full">
Active
</span>
</div>
</div>
))}
</div>
)}
</div>
</main>
</div>
)
}
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
const API = '/api'
export default function Register() {
const navigate = useNavigate()
const [form, setForm] = useState({ name: '', email: '', password: '', confirm: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
const handleSubmit = async e => {
e.preventDefault()
setError('')
if (form.password !== form.confirm) { setError('Passwords do not match.'); return }
if (form.password.length < 8) { setError('Password must be at least 8 characters.'); return }
setLoading(true)
try {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: form.name, email: form.email, password: form.password }),
})
const data = await res.json()
if (!res.ok) { setError(data.error || 'Registration failed'); return }
localStorage.setItem('token', data.token)
navigate('/dashboard')
} catch {
setError('Network error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex">
{/* ---- Left branding panel ---- */}
<div className="hidden lg:flex lg:w-1/2 relative flex-col justify-between p-12 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-green/10 via-bg to-bg" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,197,94,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(34,197,94,0.04)_1px,transparent_1px)] bg-[size:40px_40px]" />
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-green/8 rounded-full blur-[100px] -translate-x-1/2 translate-y-1/2" />
<div className="relative z-10">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 text-lg tracking-tight">TaskFlow</span>
</Link>
</div>
<div className="relative z-10">
<h2 className="font-display font-800 text-3xl text-text1 leading-tight tracking-tight mb-4">
Start organizing tasks, collaborating with your team, and scaling productivity from one platform.
</h2>
<p className="text-text2 text-sm leading-relaxed max-w-sm">
Free to start. Production-grade infrastructure. Built to grow with your team.
</p>
<div className="mt-10 flex flex-col gap-3">
{[
'Unlimited tasks on free tier',
'Team workspaces included',
'Secure by default -- JWT + PostgreSQL',
].map(item => (
<div key={item} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-accent-dim border border-accent/30 flex items-center justify-center flex-shrink-0">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="#6366f1" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>
</div>
<span className="text-sm text-text2">{item}</span>
</div>
))}
</div>
</div>
<div className="relative z-10">
<p className="text-xs text-text3">
Built by{' '}
<a href="https://www.linkedin.com/in/ikechukwu-elizabeth" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Elizabeth Ikechukwu
</a>
{' '}- DevOps &amp; Cloud Engineer
</p>
</div>
</div>
{/* ---- Right auth panel ---- */}
<div className="flex-1 flex items-center justify-center px-6 py-16">
<div className="w-full max-w-sm">
<div className="flex items-center gap-2.5 mb-10 lg:hidden">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1">TaskFlow</span>
</div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight mb-1">Create your account</h1>
<p className="text-sm text-text2 mb-8">Get started free. No credit card required.</p>
{error && (
<div className="mb-5 px-4 py-3 rounded-lg bg-red-dim border border-red/30 text-red text-sm flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Full name</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="Elizabeth Ikechukwu"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Email address</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="you@example.com"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Password</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="Min. 8 characters"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Confirm password</label>
<input
type="password"
name="confirm"
value={form.confirm}
onChange={handleChange}
placeholder="Repeat password"
required
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all duration-150"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-2 py-3 bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white font-700 rounded-lg text-sm transition-all duration-150 shadow-accent hover:shadow-accent-lg hover:-translate-y-0.5 active:translate-y-0"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="mt-6 text-center text-xs text-text3">
By creating an account you agree to our{' '}
<a href="#" className="text-text2 hover:text-accent transition-colors">Terms of Service</a>
{' '}and{' '}
<a href="#" className="text-text2 hover:text-accent transition-colors">Privacy Policy</a>.
</p>
<p className="mt-5 text-center text-sm text-text2">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline font-600">Sign in</Link>
</p>
</div>
</div>
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
const API = '/api'
function getToken() { return localStorage.getItem('token') }
function getUserFromToken() {
try {
const token = localStorage.getItem('token')
if (!token) return null
return JSON.parse(atob(token.split('.')[1]))
} catch { return null }
}
function Section({ title, description, children }) {
return (
<div className="bg-surface border border-border rounded-xl overflow-hidden mb-4">
<div className="px-6 py-4 border-b border-border">
<h2 className="font-display font-700 text-text1 text-sm">{title}</h2>
{description && <p className="text-xs text-text3 mt-0.5">{description}</p>}
</div>
<div className="px-6 py-5">{children}</div>
</div>
)
}
export default function Settings() {
const tokenUser = getUserFromToken()
const [profile, setProfile] = useState({ name: tokenUser?.name || '', email: tokenUser?.email || '' })
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
const [profileMsg, setProfileMsg] = useState({ text: '', ok: true })
const [passwordMsg, setPasswordMsg] = useState({ text: '', ok: true })
const [savingProfile, setSavingProfile] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const handleProfileSave = async e => {
e.preventDefault()
setSavingProfile(true)
setProfileMsg({ text: '', ok: true })
try {
const res = await fetch(`${API}/auth/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ name: profile.name, email: profile.email }),
})
const data = await res.json()
if (!res.ok) { setProfileMsg({ text: data.error || 'Update failed', ok: false }); return }
setProfileMsg({ text: 'Profile updated successfully.', ok: true })
} catch {
setProfileMsg({ text: 'Network error. Please try again.', ok: false })
} finally {
setSavingProfile(false)
}
}
const handlePasswordSave = async e => {
e.preventDefault()
setPasswordMsg({ text: '', ok: true })
if (password.next !== password.confirm) {
setPasswordMsg({ text: 'New passwords do not match.', ok: false }); return
}
if (password.next.length < 8) {
setPasswordMsg({ text: 'Password must be at least 8 characters.', ok: false }); return
}
setSavingPassword(true)
try {
const res = await fetch(`${API}/auth/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ currentPassword: password.current, newPassword: password.next }),
})
const data = await res.json()
if (!res.ok) { setPasswordMsg({ text: data.error || 'Password update failed', ok: false }); return }
setPasswordMsg({ text: 'Password updated successfully.', ok: true })
setPassword({ current: '', next: '', confirm: '' })
} catch {
setPasswordMsg({ text: 'Network error. Please try again.', ok: false })
} finally {
setSavingPassword(false)
}
}
const initials = profile.name
? profile.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
: 'U'
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-2xl mx-auto px-8 py-10">
<div className="mb-8">
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Settings</h1>
<p className="text-sm text-text2 mt-1">Manage your account and workspace preferences.</p>
</div>
{/* Avatar section */}
<Section title="Profile Picture" description="Your avatar is generated from your name initials.">
<div className="flex items-center gap-5">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-accent to-green flex items-center justify-center text-white text-xl font-700 flex-shrink-0">
{initials}
</div>
<div>
<p className="text-sm font-600 text-text1">{profile.name || 'Your Name'}</p>
<p className="text-xs text-text3 mt-0.5">{profile.email || 'your@email.com'}</p>
<p className="text-xs text-text3 mt-2">Avatar is auto-generated from your name. Custom avatars coming soon.</p>
</div>
</div>
</Section>
{/* Profile section */}
<Section title="Profile Information" description="Update your name and email address.">
{profileMsg.text && (
<div className={`mb-4 px-3 py-2.5 rounded-lg text-sm border ${profileMsg.ok ? 'bg-green-dim border-green/30 text-green' : 'bg-red-dim border-red/30 text-red'}`}>
{profileMsg.text}
</div>
)}
<form onSubmit={handleProfileSave} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Full Name</label>
<input
value={profile.name}
onChange={e => setProfile(p => ({ ...p, name: e.target.value }))}
placeholder="Your full name"
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Email Address</label>
<input
type="email"
value={profile.email}
onChange={e => setProfile(p => ({ ...p, email: e.target.value }))}
placeholder="you@example.com"
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
<div>
<button
type="submit"
disabled={savingProfile}
className="px-5 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-700 rounded-lg transition-all shadow-accent"
>
{savingProfile ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</Section>
{/* Password section */}
<Section title="Change Password" description="Update your password. Minimum 8 characters.">
{passwordMsg.text && (
<div className={`mb-4 px-3 py-2.5 rounded-lg text-sm border ${passwordMsg.ok ? 'bg-green-dim border-green/30 text-green' : 'bg-red-dim border-red/30 text-red'}`}>
{passwordMsg.text}
</div>
)}
<form onSubmit={handlePasswordSave} className="flex flex-col gap-4">
{[
{ label: 'Current Password', key: 'current', placeholder: 'Enter current password' },
{ label: 'New Password', key: 'next', placeholder: 'Min. 8 characters' },
{ label: 'Confirm New Password', key: 'confirm', placeholder: 'Repeat new password' },
].map(f => (
<div key={f.key}>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">{f.label}</label>
<input
type="password"
value={password[f.key]}
onChange={e => setPassword(p => ({ ...p, [f.key]: e.target.value }))}
placeholder={f.placeholder}
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
))}
<div>
<button
type="submit"
disabled={savingPassword}
className="px-5 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-700 rounded-lg transition-all shadow-accent"
>
{savingPassword ? 'Updating...' : 'Update Password'}
</button>
</div>
</form>
</Section>
{/* Danger zone */}
<Section title="Danger Zone" description="Irreversible actions. Proceed with caution.">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-600 text-text1">Delete Account</p>
<p className="text-xs text-text3 mt-0.5">Permanently delete your account and all associated data.</p>
</div>
<button
onClick={() => alert('Account deletion requires contacting support. This prevents accidental data loss.')}
className="px-4 py-2 bg-red-dim border border-red/30 text-red text-xs font-700 rounded-lg hover:bg-red hover:text-white transition-all"
>
Delete Account
</button>
</div>
</Section>
</div>
</main>
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
const SERVICES = [
{ name: 'API', description: 'REST API endpoints', endpoint: '/api/health' },
{ name: 'Frontend', description: 'Web application', endpoint: null },
{ name: 'Database', description: 'PostgreSQL data layer', endpoint: null },
{ name: 'Authentication', description: 'JWT auth service', endpoint: null },
{ name: 'Notifications', description: 'Alert delivery system', endpoint: null },
{ name: 'File Storage', description: 'Asset and attachment storage', endpoint: null },
]
const INCIDENTS = [
{
date: 'May 28, 2026',
title: 'All systems operational',
status: 'resolved',
message: 'No incidents reported. Platform running normally.',
},
{
date: 'May 20, 2026',
title: 'Scheduled maintenance -- database upgrade',
status: 'resolved',
message: 'PostgreSQL version upgrade completed successfully. Downtime: 4 minutes.',
},
{
date: 'May 14, 2026',
title: 'API latency increase',
status: 'resolved',
message: 'Briefly elevated response times due to high traffic. Auto-scaling resolved the issue within 8 minutes.',
},
]
const UPTIME = [
{ service: 'API', uptime: '99.98%', days: 30 },
{ service: 'Frontend', uptime: '100%', days: 30 },
{ service: 'Database', uptime: '99.95%', days: 30 },
{ service: 'Auth Service', uptime: '99.99%', days: 30 },
]
function StatusBadge({ status }) {
if (status === 'operational') return (
<div className="flex items-center gap-1.5 text-xs font-600 text-green">
<span className="w-2 h-2 rounded-full bg-green animate-pulse" />
Operational
</div>
)
if (status === 'degraded') return (
<div className="flex items-center gap-1.5 text-xs font-600 text-yellow-400">
<span className="w-2 h-2 rounded-full bg-yellow-400" />
Degraded
</div>
)
if (status === 'down') return (
<div className="flex items-center gap-1.5 text-xs font-600 text-red">
<span className="w-2 h-2 rounded-full bg-red" />
Down
</div>
)
return null
}
export default function Status() {
const [apiStatus, setApiStatus] = useState('checking')
const [apiLatency, setApiLatency] = useState(null)
useEffect(() => {
const start = Date.now()
fetch('/api/health')
.then(r => r.json())
.then(() => {
setApiStatus('operational')
setApiLatency(Date.now() - start)
})
.catch(() => setApiStatus('down'))
}, [])
const getServiceStatus = (name) => {
if (name === 'API') return apiStatus === 'checking' ? 'operational' : apiStatus
return 'operational'
}
const allOperational = SERVICES.every(s => getServiceStatus(s.name) === 'operational')
return (
<div className="min-h-screen bg-bg flex flex-col">
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-border bg-bg/80 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-accent flex items-center justify-center glow-pulse">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<span className="font-display font-800 text-text1 tracking-tight">TaskFlow</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<a href="/#features" className="text-sm text-text2 hover:text-text1 transition-colors">Features</a>
<Link to="/pricing" className="text-sm text-text2 hover:text-text1 transition-colors">Pricing</Link>
<Link to="/about" className="text-sm text-text2 hover:text-text1 transition-colors">About</Link>
</nav>
<div className="flex items-center gap-3">
<Link to="/login" className="text-sm text-text2 hover:text-text1 transition-colors px-3 py-1.5">Sign In</Link>
<Link to="/register" className="text-sm font-600 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg transition-all shadow-accent hover:-translate-y-0.5">
Get Started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className={`py-16 border-b border-border text-center ${allOperational ? 'bg-green-dim/30' : 'bg-red-dim/30'}`}>
<div className="max-w-2xl mx-auto px-6">
<div className={`inline-flex items-center gap-2.5 px-5 py-2.5 rounded-full border mb-5 ${allOperational ? 'bg-green-dim border-green/30' : 'bg-red-dim border-red/30'}`}>
<span className={`w-2.5 h-2.5 rounded-full animate-pulse ${allOperational ? 'bg-green' : 'bg-red'}`} />
<span className={`text-sm font-700 ${allOperational ? 'text-green' : 'text-red'}`}>
{allOperational ? 'All Systems Operational' : 'Service Disruption Detected'}
</span>
</div>
<h1 className="font-display font-800 text-3xl text-text1 tracking-tight mb-3">System Status</h1>
<p className="text-sm text-text2">
Monitor platform uptime, API availability, infrastructure health, and operational performance in real time.
</p>
<p className="text-xs text-text3 mt-3">
Last checked: {new Date().toLocaleString()}
</p>
</div>
</section>
{/* Services */}
<section className="py-12 border-b border-border">
<div className="max-w-3xl mx-auto px-6">
<h2 className="font-display font-700 text-text1 text-sm uppercase tracking-wider mb-4">Service Health</h2>
<div className="bg-surface border border-border rounded-2xl overflow-hidden">
{SERVICES.map((service, i) => (
<div key={service.name} className={`flex items-center justify-between px-5 py-4 ${i < SERVICES.length - 1 ? 'border-b border-border' : ''} hover:bg-surface2 transition-colors`}>
<div>
<p className="text-sm font-600 text-text1">{service.name}</p>
<p className="text-xs text-text3 mt-0.5">{service.description}</p>
</div>
<div className="flex items-center gap-4">
{service.name === 'API' && apiLatency && (
<span className="text-xs text-text3">{apiLatency}ms</span>
)}
<StatusBadge status={getServiceStatus(service.name)} />
</div>
</div>
))}
</div>
</div>
</section>
{/* Uptime */}
<section className="py-12 border-b border-border">
<div className="max-w-3xl mx-auto px-6">
<h2 className="font-display font-700 text-text1 text-sm uppercase tracking-wider mb-4">30-Day Uptime</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{UPTIME.map((u, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-4 text-center">
<p className="font-display font-800 text-xl text-green mb-1">{u.uptime}</p>
<p className="text-xs text-text2">{u.service}</p>
<p className="text-[0.6rem] text-text3 mt-1">Last {u.days} days</p>
</div>
))}
</div>
</div>
</section>
{/* Incident history */}
<section className="py-12">
<div className="max-w-3xl mx-auto px-6">
<h2 className="font-display font-700 text-text1 text-sm uppercase tracking-wider mb-4">Incident History</h2>
<div className="flex flex-col gap-3">
{INCIDENTS.map((incident, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<h3 className="text-sm font-600 text-text1">{incident.title}</h3>
<span className={`text-[0.65rem] font-600 px-2 py-0.5 rounded-full border flex-shrink-0 ${incident.status === 'resolved' ? 'text-green bg-green-dim border-green/20' : 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20'}`}>
{incident.status}
</span>
</div>
<p className="text-xs text-text2 leading-relaxed mb-2">{incident.message}</p>
<p className="text-[0.65rem] text-text3">{incident.date}</p>
</div>
))}
</div>
</div>
</section>
<Footer />
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import { EmptyTasks } from '../components/EmptyState'
const API = '/api'
function getToken() { return localStorage.getItem('token') }
function authHeader() { return { Authorization: `Bearer ${getToken()}` } }
export default function Tasks() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [title, setTitle] = useState('')
const [adding, setAdding] = useState(false)
const [filter, setFilter] = useState('all') // all | active | completed
const fetchTasks = async () => {
try {
const res = await fetch(`${API}/tasks`, { headers: authHeader() })
const data = await res.json()
setTasks(Array.isArray(data) ? data : [])
} catch { setTasks([]) }
finally { setLoading(false) }
}
useEffect(() => { fetchTasks() }, [])
const addTask = async () => {
if (!title.trim()) return
setAdding(true)
await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ title }),
})
setTitle('')
setAdding(false)
fetchTasks()
}
const toggleTask = async id => {
await fetch(`${API}/tasks/${id}`, { method: 'PATCH', headers: authHeader() })
fetchTasks()
}
const deleteTask = async id => {
await fetch(`${API}/tasks/${id}`, { method: 'DELETE', headers: authHeader() })
fetchTasks()
}
const filtered = tasks.filter(t =>
filter === 'all' ? true : filter === 'active' ? !t.completed : t.completed
)
const completed = tasks.filter(t => t.completed).length
const active = tasks.filter(t => !t.completed).length
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-3xl mx-auto px-8 py-10">
{/* Header */}
<div className="mb-8">
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Tasks</h1>
<p className="text-sm text-text2 mt-1">Manage and track all your tasks in one place.</p>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ label: 'Total', value: tasks.length, color: 'text-text1' },
{ label: 'Active', value: active, color: 'text-[#818cf8]' },
{ label: 'Completed', value: completed, color: 'text-green' },
].map(s => (
<div key={s.label} className="bg-surface border border-border rounded-xl p-4 text-center">
<p className={`font-display font-800 text-2xl ${s.color}`}>{s.value}</p>
<p className="text-xs text-text2 uppercase tracking-wider mt-1">{s.label}</p>
</div>
))}
</div>
{/* Input */}
<div className="flex gap-3 mb-6">
<input
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTask()}
placeholder="Add a new task and press Enter..."
className="flex-1 bg-surface border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
<button
onClick={addTask}
disabled={adding || !title.trim()}
className="px-5 py-3 bg-accent hover:bg-accent-hover disabled:opacity-50 text-white text-sm font-700 rounded-lg transition-all shadow-accent whitespace-nowrap"
>
{adding ? '...' : '+ Add'}
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-1 mb-6 bg-surface2 border border-border rounded-lg p-1 w-fit">
{['all', 'active', 'completed'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-1.5 rounded-md text-xs font-600 capitalize transition-all duration-150
${filter === f ? 'bg-accent text-white shadow-accent' : 'text-text2 hover:text-text1'}`}
>
{f}
</button>
))}
</div>
{/* Task list */}
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && tasks.length === 0 && (
<EmptyTasks onAdd={() => document.querySelector('input')?.focus()} />
)}
{!loading && tasks.length > 0 && filtered.length === 0 && (
<div className="text-center py-16">
<p className="text-text2 text-sm">No {filter} tasks found.</p>
</div>
)}
{!loading && filtered.length > 0 && (
<div className="flex flex-col gap-2">
{filtered.map(task => (
<div
key={task.id}
className={`flex items-center gap-3 rounded-xl border px-4 py-3.5 transition-all duration-150 animate-slide-in
${task.completed
? 'bg-green-dim border-green/20'
: 'bg-surface border-border hover:border-accent/40 hover:bg-surface2'
}`}
>
<button
onClick={() => toggleTask(task.id)}
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all
${task.completed ? 'bg-green border-green' : 'border-text3 hover:border-green hover:bg-green-dim'}`}
>
{task.completed && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2.5">
<polyline points="1.5,6 5,9.5 10.5,2.5"/>
</svg>
)}
</button>
<span className={`flex-1 text-sm font-500 ${task.completed ? 'line-through text-text3 decoration-green' : 'text-text1'}`}>
{task.title}
</span>
<span className="text-[0.65rem] text-text3 flex-shrink-0 hidden sm:block">
{new Date(task.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<button
onClick={() => deleteTask(task.id)}
className="flex-shrink-0 p-1.5 rounded-lg text-text3 hover:text-red hover:bg-red-dim transition-all"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
</main>
</div>
)
}
+212
View File
@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import Sidebar from '../components/Sidebar'
import { EmptyTeam } from '../components/EmptyState'
const API = '/api'
function getToken() { return localStorage.getItem('token') }
function getUserFromToken() {
try {
const token = localStorage.getItem('token')
if (!token) return null
return JSON.parse(atob(token.split('.')[1]))
} catch { return null }
}
export default function Teams() {
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(true)
const [showing, setShowing] = useState(false)
const [email, setEmail] = useState('')
const [role, setRole] = useState('member')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const currentUser = getUserFromToken()
const fetchMembers = async () => {
try {
const res = await fetch(`${API}/team`, { headers: { Authorization: `Bearer ${getToken()}` } })
const data = await res.json()
setMembers(Array.isArray(data) ? data : [])
} catch { setMembers([]) }
finally { setLoading(false) }
}
useEffect(() => { fetchMembers() }, [])
const handleInvite = async e => {
e.preventDefault()
if (!email.trim()) return
setSaving(true)
setError('')
setSuccess('')
try {
const res = await fetch(`${API}/team/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ email, role }),
})
const data = await res.json()
if (!res.ok) { setError(data.error || 'Failed to send invite'); return }
setSuccess(`Invitation sent to ${email}`)
setEmail('')
setShowing(false)
fetchMembers()
} catch {
setError('Network error. Please try again.')
} finally {
setSaving(false)
}
}
const handleRemove = async id => {
if (!window.confirm('Remove this team member?')) return
await fetch(`${API}/team/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
})
fetchMembers()
}
const ROLE_COLORS = {
admin: 'text-accent bg-accent-dim border-accent/20',
member: 'text-green bg-green-dim border-green/20',
viewer: 'text-text2 bg-surface2 border-border',
}
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="ml-[240px] flex-1 min-h-screen overflow-y-auto">
<div className="max-w-3xl mx-auto px-8 py-10">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display font-800 text-2xl text-text1 tracking-tight">Teams</h1>
<p className="text-sm text-text2 mt-1">Manage your workspace members and roles.</p>
</div>
<button
onClick={() => setShowing(true)}
className="px-4 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-700 rounded-lg transition-all shadow-accent hover:-translate-y-0.5 flex items-center gap-2"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Invite Member
</button>
</div>
{success && (
<div className="mb-5 px-4 py-3 rounded-lg bg-green-dim border border-green/30 text-green text-sm flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
{success}
</div>
)}
{/* Invite modal */}
{showing && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-2xl w-full max-w-md p-6 animate-fade-up">
<h2 className="font-display font-700 text-text1 text-lg mb-5">Invite Team Member</h2>
{error && (
<div className="mb-4 px-3 py-2.5 rounded-lg bg-red-dim border border-red/30 text-red text-sm">{error}</div>
)}
<form onSubmit={handleInvite} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Email Address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="teammate@example.com"
required
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 placeholder-text3 outline-none focus:border-accent focus:ring-2 focus:ring-accent/15 transition-all"
/>
</div>
<div>
<label className="block text-xs font-600 text-text2 uppercase tracking-wider mb-2">Role</label>
<select
value={role}
onChange={e => setRole(e.target.value)}
className="w-full bg-surface2 border border-border rounded-lg px-4 py-3 text-sm text-text1 outline-none focus:border-accent transition-all"
>
<option value="admin">Admin -- full access</option>
<option value="member">Member -- can create and edit</option>
<option value="viewer">Viewer -- read only</option>
</select>
</div>
<div className="flex gap-3 mt-2">
<button type="button" onClick={() => { setShowing(false); setError('') }}
className="flex-1 py-2.5 bg-surface2 hover:bg-border text-text2 text-sm font-600 rounded-lg border border-border transition-all">
Cancel
</button>
<button type="submit" disabled={saving}
className="flex-1 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-700 rounded-lg transition-all shadow-accent">
{saving ? 'Sending...' : 'Send Invite'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Members list */}
<div className="bg-surface border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="font-display font-700 text-text1 text-sm tracking-tight">Workspace Members</h2>
<span className="text-xs text-text3 bg-surface2 border border-border px-2.5 py-1 rounded-full">
{members.length + 1} member{members.length !== 0 ? 's' : ''}
</span>
</div>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Current user always shown */}
<div className="px-6 py-4 flex items-center gap-4 border-b border-border bg-surface2">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-accent to-green flex items-center justify-center text-white text-xs font-700 flex-shrink-0">
{currentUser?.name?.charAt(0)?.toUpperCase() || 'Y'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-600 text-text1">{currentUser?.name || 'You'} <span className="text-text3 font-400">(you)</span></p>
<p className="text-xs text-text3">{currentUser?.email || ''}</p>
</div>
<span className="text-[0.65rem] font-600 px-2 py-0.5 rounded-full border text-accent bg-accent-dim border-accent/20">
Admin
</span>
</div>
{!loading && members.length === 0 && (
<EmptyTeam onInvite={() => setShowing(true)} />
)}
{!loading && members.map(member => (
<div key={member.id} className="px-6 py-4 flex items-center gap-4 border-b border-border last:border-0 hover:bg-surface2 transition-colors group">
<div className="w-9 h-9 rounded-full bg-surface2 border border-border flex items-center justify-center text-text2 text-xs font-700 flex-shrink-0">
{member.name?.charAt(0)?.toUpperCase() || member.email?.charAt(0)?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-600 text-text1 truncate">{member.name || 'Invited User'}</p>
<p className="text-xs text-text3 truncate">{member.email}</p>
</div>
<span className={`text-[0.65rem] font-600 px-2 py-0.5 rounded-full border ${ROLE_COLORS[member.role] || ROLE_COLORS.member}`}>
{member.role || 'member'}
</span>
<button
onClick={() => handleRemove(member.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-text3 hover:text-red hover:bg-red-dim transition-all"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
))}
</div>
</div>
</main>
</div>
)
}
@@ -0,0 +1,128 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Data-Driven Productivity Insights',
description: 'Monitor team performance, task completion rates, operational efficiency, and project progress through visual analytics and reporting tools. Turn raw activity into actionable insights.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
color: 'text-purple-400',
bg: 'bg-purple-400/10 border-purple-400/20',
}
const CAPABILITIES = [
{ title: 'Productivity Charts', description: 'Bar and line charts showing tasks created and completed over time. See your velocity and identify slowdowns before they become problems.', icon: '📈' },
{ title: 'Completion Metrics', description: 'Track your overall completion rate, average task age, and streak of productive days to build consistent habits.', icon: '✅' },
{ title: 'Performance Reports', description: 'Weekly and monthly summaries of team output. Shareable reports for standups, retrospectives, and stakeholder updates.', icon: '📋' },
{ title: 'Activity Monitoring', description: 'See which projects and team members are most active. Identify blockers and redistribute work before deadlines are missed.', icon: '👁️' },
{ title: 'Task Breakdown', description: 'Pie charts showing the ratio of active vs completed tasks, filtered by project, team member, or label.', icon: '🥧' },
{ title: 'Trend Analysis', description: 'Week-over-week and month-over-month trend lines showing whether your team is accelerating or slowing down.', icon: '📊' },
]
const MOCK_BARS = [
{ day: 'Mon', created: 4, completed: 2 },
{ day: 'Tue', created: 2, completed: 3 },
{ day: 'Wed', created: 6, completed: 5 },
{ day: 'Thu', created: 3, completed: 4 },
{ day: 'Fri', created: 5, completed: 6 },
{ day: 'Sat', created: 1, completed: 2 },
{ day: 'Sun', created: 2, completed: 1 },
]
const MAX_VAL = 8
export default function AnalyticsFeature() {
return (
<FeaturePageLayout hero={HERO}>
{/* Chart preview */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Live Preview</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">Your analytics dashboard</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 max-w-4xl mx-auto">
{/* Stats */}
<div className="flex flex-col gap-4">
{[
{ label: 'Total Tasks', value: '24', color: 'text-text1' },
{ label: 'Completed', value: '18', color: 'text-green' },
{ label: 'Completion Rate', value: '75%', color: 'text-accent' },
].map(s => (
<div key={s.label} className="bg-surface border border-border rounded-xl p-4 text-center">
<p className={`font-display font-800 text-3xl ${s.color}`}>{s.value}</p>
<p className="text-xs text-text2 uppercase tracking-wider mt-1">{s.label}</p>
</div>
))}
</div>
{/* Bar chart mock */}
<div className="lg:col-span-2 bg-surface border border-border rounded-xl p-5">
<h3 className="font-display font-700 text-text1 text-xs mb-4 uppercase tracking-wider">Daily Activity -- Last 7 Days</h3>
<div className="flex items-end gap-2 h-32">
{MOCK_BARS.map((b, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full flex flex-col items-center gap-0.5">
<div
className="w-full bg-accent rounded-t transition-all"
style={{ height: `${(b.created / MAX_VAL) * 80}px` }}
/>
<div
className="w-full bg-green rounded-t transition-all"
style={{ height: `${(b.completed / MAX_VAL) * 80}px` }}
/>
</div>
<span className="text-[0.6rem] text-text3">{b.day}</span>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-3">
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-sm bg-accent" /><span className="text-[0.65rem] text-text2">Created</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-sm bg-green" /><span className="text-[0.65rem] text-text2">Completed</span></div>
</div>
</div>
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Measure what matters</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Real data from your actual work -- no vanity metrics, just the numbers that help you ship better.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Start tracking your productivity</h2>
<p className="text-sm text-text2 mb-8">See your real numbers and build the habit of consistent, measurable output.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
View Your Analytics
</a>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,143 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Built for Scale and Reliability',
description: 'Designed with scalable cloud infrastructure and modern engineering practices to support growing teams, increasing workloads, and enterprise-level operations. Built by a DevOps engineer, for engineers.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
),
color: 'text-accent',
bg: 'bg-accent-dim border-accent/20',
}
const STACK = [
{ layer: 'Frontend', tech: 'React + Vite + Tailwind CSS', note: 'Built as a static bundle, served by Nginx', color: 'text-blue-400', bg: 'bg-blue-400/10 border-blue-400/20' },
{ layer: 'Reverse Proxy', tech: 'Nginx', note: 'Forwards /api/* to Express, serves static files', color: 'text-green', bg: 'bg-green-dim border-green/20' },
{ layer: 'Backend API', tech: 'Node.js + Express', note: 'REST API with JWT auth, scoped to authenticated users', color: 'text-accent', bg: 'bg-accent-dim border-accent/20' },
{ layer: 'Database', tech: 'PostgreSQL on AWS RDS', note: 'Managed RDS instance in a private subnet, t3.micro', color: 'text-purple-400', bg: 'bg-purple-400/10 border-purple-400/20' },
{ layer: 'Compute', tech: 'AWS EC2 t3.micro', note: 'Docker Compose runs Nginx + Node in containers', color: 'text-yellow-400', bg: 'bg-yellow-400/10 border-yellow-400/20' },
{ layer: 'Infrastructure', tech: 'Terraform', note: 'VPC, EC2, RDS, IAM, S3 backend, security groups as code', color: 'text-red-400', bg: 'bg-red-400/10 border-red-400/20' },
{ layer: 'CI/CD', tech: 'GitHub Actions + OIDC', note: 'Terraform plan on PR, Terraform apply and deploy on merge', color: 'text-accent', bg: 'bg-accent-dim border-accent/20' },
{ layer: 'Secrets', tech: 'AWS SSM Parameter Store', note: 'DB credentials fetched at EC2 startup, never in code', color: 'text-green', bg: 'bg-green-dim border-green/20' },
]
const CAPABILITIES = [
{ title: 'Infrastructure as Code', description: 'Every AWS resource -- VPC, EC2, RDS, IAM, security groups -- is defined in Terraform. Reproducible, versionable, reviewable.', icon: '📝' },
{ title: 'Containerized Workloads', description: 'Frontend and backend run in Docker containers orchestrated with Docker Compose. Consistent environments from local to production.', icon: '🐳' },
{ title: 'Automated CI/CD', description: 'Every push to main triggers a GitHub Actions pipeline that builds, tests, and deploys automatically using OIDC auth.', icon: '🚀' },
{ title: 'Private Networking', description: 'RDS lives in a private subnet with no public access. Only the EC2 instance can reach the database through a security group rule.', icon: '🔒' },
{ title: 'High Availability', description: 'AWS RDS supports Multi-AZ for automatic failover. The architecture is designed to scale to EKS when the time comes.', icon: '🏗️' },
{ title: 'Kubernetes Ready', description: 'The entire stack is containerized and ready to migrate to EKS. Helm charts and Kubernetes manifests are the next step.', icon: '☸️' },
]
export default function Architecture() {
return (
<FeaturePageLayout hero={HERO}>
{/* Stack diagram */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Tech Stack</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">Layer by layer</h2>
</div>
<div className="max-w-3xl mx-auto flex flex-col gap-2">
{STACK.map((s, i) => (
<div key={i} className="flex items-start gap-4 bg-surface border border-border rounded-xl px-5 py-4 hover:bg-surface2 transition-colors group">
<div className={`px-2.5 py-1 rounded-lg border text-xs font-700 flex-shrink-0 w-32 text-center ${s.bg} ${s.color}`}>
{s.layer}
</div>
<div className="flex-1">
<p className="text-sm font-600 text-text1 mb-0.5">{s.tech}</p>
<p className="text-xs text-text3">{s.note}</p>
</div>
<div className="w-px h-8 bg-border self-center hidden sm:block" />
<div className="w-6 h-6 rounded-full bg-green-dim border border-green/30 flex items-center justify-center flex-shrink-0 hidden sm:flex">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>
</div>
</div>
))}
</div>
</div>
</section>
{/* DevOps pipeline */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">CI/CD Pipeline</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">From commit to production</h2>
</div>
<div className="flex flex-wrap items-center justify-center gap-2 max-w-4xl mx-auto">
{[
{ step: '1', label: 'Push to GitHub', icon: '📤' },
{ step: '2', label: 'GitHub Actions triggers', icon: '⚙️' },
{ step: '3', label: 'Build Docker images', icon: '🐳' },
{ step: '4', label: 'Push to ECR', icon: '📦' },
{ step: '5', label: 'Terraform apply', icon: '🏗️' },
{ step: '6', label: 'EC2 pulls and restarts', icon: '🚀' },
{ step: '7', label: 'Live on devopsbliss.online', icon: '✅' },
].map((s, i, arr) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col items-center gap-1.5 bg-surface border border-border rounded-xl px-4 py-3 text-center min-w-[100px]">
<span className="text-xl">{s.icon}</span>
<span className="text-[0.65rem] font-600 text-accent">Step {s.step}</span>
<span className="text-xs text-text2 leading-tight">{s.label}</span>
</div>
{i < arr.length - 1 && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3d4257" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
)}
</div>
))}
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Production-grade from week one</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Not a toy app. A real infrastructure story built by a DevOps engineer learning in public.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Built by a DevOps Engineer</h2>
<p className="text-sm text-text2 mb-2 max-w-lg mx-auto">
This platform was designed and built by Elizabeth Ikechukwu as part of a 10-week DevOps portfolio program through the Digital Witch Support Community, mentored by Oz.
</p>
<p className="text-xs text-text3 mb-8">AWS - Terraform - Docker - GitHub Actions - Node.js - PostgreSQL - React</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="https://github.com/elizabeth-ikechukwu/scalable-task-platform" target="_blank" rel="noopener noreferrer"
className="px-8 py-3.5 bg-surface hover:bg-surface2 text-text1 font-700 rounded-xl text-sm transition-all border border-border hover:border-accent/40 flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.202 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/></svg>
View on GitHub
</a>
<a href="/register" className="px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Try the Platform
</a>
</div>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,105 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Never Miss Important Updates',
description: 'Receive intelligent real-time notifications for task assignments, project activity, approaching deadlines, mentions, and workflow updates. Stay informed without being overwhelmed.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
),
color: 'text-yellow-400',
bg: 'bg-yellow-400/10 border-yellow-400/20',
}
const CAPABILITIES = [
{ title: 'Real-Time Alerts', description: 'Get notified the moment a task is assigned to you, a deadline is approaching, or a teammate mentions you in a comment.', icon: '⚡' },
{ title: 'Deadline Reminders', description: 'Automatic reminders before task due dates. Never miss a deadline because you forgot to check.', icon: '⏰' },
{ title: 'Task Update Alerts', description: 'Know immediately when a task you own or follow is updated, commented on, or moved to a new status.', icon: '🔄' },
{ title: 'Mention Notifications', description: 'When someone tags you with @mention in a task comment, you get a direct notification with full context.', icon: '@' },
{ title: 'Notification Center', description: 'All your alerts in one place. Mark as read, filter by type, and manage your notification preferences from the sidebar.', icon: '🔔' },
{ title: 'Quiet Hours', description: 'Set quiet hours so you are not disturbed outside of work. TaskFlow respects your focus time.', icon: '🌙' },
]
const MOCK_NOTIFICATIONS = [
{ type: 'mention', title: 'You were mentioned', message: 'Tarak mentioned you in "SSM Parameter Store integration"', time: '2m ago', read: false, color: 'text-accent bg-accent-dim border-accent/20' },
{ type: 'deadline', title: 'Deadline approaching', message: '"Deploy to Kubernetes" is due in 2 days', time: '1h ago', read: false, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
{ type: 'completed', title: 'Task completed', message: '"Set up RDS with Terraform" was marked complete', time: '3h ago', read: true, color: 'text-green bg-green-dim border-green/20' },
{ type: 'assigned', title: 'Task assigned to you', message: '"Implement JWT authentication" was assigned to you', time: '5h ago', read: true, color: 'text-accent bg-accent-dim border-accent/20' },
]
export default function NotificationsFeature() {
return (
<FeaturePageLayout hero={HERO}>
{/* Notification center preview */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Live Preview</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">Your notification center</h2>
</div>
<div className="bg-surface border border-border rounded-2xl overflow-hidden max-w-xl mx-auto">
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-display font-700 text-text1 text-sm">Notifications</h3>
<span className="bg-accent text-white text-[0.6rem] font-700 px-1.5 py-0.5 rounded-full">2</span>
</div>
<button className="text-xs font-600 text-accent">Mark all read</button>
</div>
<div className="p-3 flex flex-col gap-2">
{MOCK_NOTIFICATIONS.map((n, i) => (
<div key={i} className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${n.read ? 'bg-surface border-border opacity-60' : 'bg-surface2 border-border'}`}>
<div className={`w-7 h-7 rounded-full border flex items-center justify-center text-xs flex-shrink-0 ${n.color}`}>
{n.type === 'mention' ? '@' : n.type === 'deadline' ? '⏰' : n.type === 'completed' ? '✓' : '→'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<p className="text-xs font-600 text-text1">{n.title}</p>
{!n.read && <span className="w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />}
</div>
<p className="text-[0.65rem] text-text2 leading-snug">{n.message}</p>
</div>
<span className="text-[0.6rem] text-text3 flex-shrink-0">{n.time}</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Stay informed without the noise</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Smart notifications that surface what matters and stay quiet when you need focus.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Stay on top of everything</h2>
<p className="text-sm text-text2 mb-8">Never miss a deadline, mention, or update that matters to your work.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Free
</a>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,101 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Centralized Project Workspaces',
description: 'Organize tasks, milestones, files, and team collaboration inside dedicated project environments designed for scalable operations and efficient delivery.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 7a2 2 0 012-2h4l2 3h9a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V7z"/>
</svg>
),
color: 'text-blue-400',
bg: 'bg-blue-400/10 border-blue-400/20',
}
const CAPABILITIES = [
{ title: 'Project Dashboards', description: 'Each project has its own dashboard showing task counts, progress, team members, and recent activity at a glance.', icon: '📊' },
{ title: 'Milestones', description: 'Define project milestones to track major checkpoints. See how much of the project is complete toward each milestone.', icon: '🏁' },
{ title: 'Timeline Tracking', description: 'View tasks on a timeline to spot bottlenecks, overlapping work, and critical path dependencies.', icon: '📅' },
{ title: 'Team Assignments', description: 'Assign team members to projects. Control who has access and what they can do within each project workspace.', icon: '👥' },
{ title: 'Shared Resources', description: 'Attach links, files, and documentation directly to a project so everything your team needs is in one place.', icon: '📁' },
{ title: 'Project Templates', description: 'Create reusable project templates for common workflows -- sprint cycles, feature launches, infrastructure setups, and more.', icon: '🗂️' },
]
const MOCK_PROJECTS = [
{ name: 'AWS Infrastructure', tasks: 12, done: 8, color: 'from-accent to-purple-500', initial: 'A' },
{ name: 'TaskFlow Backend API', tasks: 18, done: 14, color: 'from-green to-accent', initial: 'T' },
{ name: 'Kubernetes Migration', tasks: 7, done: 2, color: 'from-blue-500 to-accent', initial: 'K' },
{ name: 'Frontend UI Overhaul', tasks: 24, done: 20, color: 'from-yellow-500 to-green', initial: 'F' },
]
export default function ProjectWorkspaces() {
return (
<FeaturePageLayout hero={HERO}>
{/* Projects preview */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Live Preview</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">Your project workspaces</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-3xl mx-auto">
{MOCK_PROJECTS.map((p, i) => {
const progress = Math.round((p.done / p.tasks) * 100)
return (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200 group">
<div className="flex items-start justify-between mb-4">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${p.color} flex items-center justify-center text-white font-700 text-sm`}>
{p.initial}
</div>
<span className="text-[0.65rem] font-600 text-accent bg-accent-dim border border-accent/20 px-2 py-0.5 rounded-full">Active</span>
</div>
<h3 className="font-display font-700 text-text1 text-sm mb-3 group-hover:text-accent transition-colors">{p.name}</h3>
<div className="flex items-center justify-between text-xs text-text3 mb-2">
<span>{p.done}/{p.tasks} tasks</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 bg-border rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-accent to-green rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)
})}
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">One workspace per project</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Keep every project organized, focused, and accessible to the right people.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Organize your work into projects</h2>
<p className="text-sm text-text2 mb-8">Create your first project workspace and start delivering work with clarity and focus.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Create Your First Project
</a>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,114 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Enterprise-Grade Security and Permissions',
description: 'Manage secure collaboration with role-based access control, workspace permissions, and protected infrastructure designed for growing organizations. Your data stays private by design.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
</svg>
),
color: 'text-red-400',
bg: 'bg-red-400/10 border-red-400/20',
}
const CAPABILITIES = [
{ title: 'JWT Authentication', description: 'Every API request is authenticated with a JSON Web Token. Tokens expire after 7 days and are validated on every request.', icon: '🔑' },
{ title: 'Password Hashing', description: 'Passwords are hashed with bcrypt at cost factor 12 before storage. Plain text passwords are never stored or logged.', icon: '🔒' },
{ title: 'Role-Based Access', description: 'Three roles -- Admin, Member, Viewer -- control exactly what each user can see and do within a workspace.', icon: '👥' },
{ title: 'User Isolation', description: 'Every task, project, and notification is scoped to the authenticated user. No cross-user data leakage by design.', icon: '🛡️' },
{ title: 'SSM Parameter Store', description: 'Database credentials are stored in AWS SSM Parameter Store, not hardcoded in code or environment files on EC2.', icon: '🗝️' },
{ title: 'HTTPS Everywhere', description: 'All traffic is encrypted in transit via HTTPS. The backend never serves unencrypted HTTP in production.', icon: '🔐' },
]
const ROLES = [
{ role: 'Admin', create: true, edit: true, delete: true, invite: true, view: true },
{ role: 'Member', create: true, edit: true, delete: false, invite: false, view: true },
{ role: 'Viewer', create: false, edit: false, delete: false, invite: false, view: true },
]
const PERMISSIONS = ['Create Tasks', 'Edit Tasks', 'Delete Tasks', 'Invite Members', 'View Projects']
function Check({ val }) {
return val ? (
<div className="w-5 h-5 rounded-full bg-green-dim border border-green/30 flex items-center justify-center mx-auto">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>
</div>
) : (
<div className="w-5 h-5 rounded-full bg-red-dim border border-red/20 flex items-center justify-center mx-auto">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none" stroke="#ef4444" strokeWidth="2.5"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</div>
)
}
export default function Security() {
return (
<FeaturePageLayout hero={HERO}>
{/* Permission matrix */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Role Matrix</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">Who can do what</h2>
</div>
<div className="overflow-x-auto max-w-2xl mx-auto">
<table className="w-full bg-surface border border-border rounded-xl overflow-hidden">
<thead>
<tr className="border-b border-border bg-surface2">
<th className="px-5 py-3 text-left text-xs font-700 text-text3 uppercase tracking-wider">Permission</th>
{ROLES.map(r => (
<th key={r.role} className="px-4 py-3 text-center text-xs font-700 text-text3 uppercase tracking-wider">{r.role}</th>
))}
</tr>
</thead>
<tbody>
{PERMISSIONS.map((perm, i) => (
<tr key={perm} className={`border-b border-border ${i % 2 === 0 ? '' : 'bg-surface2/30'}`}>
<td className="px-5 py-3 text-sm text-text2">{perm}</td>
<td className="px-4 py-3"><Check val={ROLES[0][Object.keys(ROLES[0])[i + 1]]} /></td>
<td className="px-4 py-3"><Check val={ROLES[1][Object.keys(ROLES[1])[i + 1]]} /></td>
<td className="px-4 py-3"><Check val={ROLES[2][Object.keys(ROLES[2])[i + 1]]} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Secure by design, not by default</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Security decisions were made at the architecture level -- not bolted on after the fact.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Your data is safe with TaskFlow</h2>
<p className="text-sm text-text2 mb-8">Built on AWS with JWT auth, bcrypt passwords, SSM secrets, and role-based access from day one.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Securely
</a>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,138 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Task Management Built for Productivity',
description: 'Create, organize, prioritize, and monitor tasks using flexible workflows designed for modern teams. Track deadlines, manage priorities, and maintain full visibility across projects from one centralized workspace.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
),
color: 'text-accent',
bg: 'bg-accent-dim border-accent/20',
}
const CAPABILITIES = [
{
title: 'Kanban Boards',
description: 'Visualize work in progress with drag-and-drop Kanban boards. Move tasks across columns -- To Do, In Progress, Review, Done -- and see your workflow at a glance.',
icon: '⬛',
},
{
title: 'Task Priorities',
description: 'Mark tasks as urgent, high, medium, or low priority. Filter and sort by priority to always work on what matters most.',
icon: '🔴',
},
{
title: 'Due Dates',
description: 'Set deadlines on individual tasks. Tasks approaching their due date are highlighted so nothing slips through the cracks.',
icon: '📅',
},
{
title: 'Labels and Tags',
description: 'Categorize tasks with color-coded labels. Group by feature, team, sprint, or any custom taxonomy that fits your workflow.',
icon: '🏷️',
},
{
title: 'Task Filtering',
description: 'Filter tasks by status, assignee, priority, label, or date range. Find exactly what you need in seconds across large task lists.',
icon: '🔍',
},
{
title: 'Activity Tracking',
description: 'Every task change is logged. See who created, updated, or completed a task and when -- giving you a full audit trail.',
icon: '📊',
},
]
const MOCK_TASKS = [
{ title: 'Set up RDS with Terraform', priority: 'high', status: 'done', label: 'Infrastructure' },
{ title: 'Configure OIDC GitHub Actions', priority: 'high', status: 'done', label: 'CI/CD' },
{ title: 'Implement JWT authentication', priority: 'urgent', status: 'in-progress', label: 'Backend' },
{ title: 'SSM Parameter Store integration', priority: 'medium', status: 'todo', label: 'Security' },
{ title: 'Build Analytics dashboard', priority: 'medium', status: 'todo', label: 'Frontend' },
{ title: 'Deploy to Kubernetes', priority: 'low', status: 'todo', label: 'DevOps' },
]
const PRIORITY_COLORS = {
urgent: 'text-red bg-red-dim border-red/20',
high: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
medium: 'text-accent bg-accent-dim border-accent/20',
low: 'text-text3 bg-surface2 border-border',
}
const STATUS_COLORS = {
done: 'text-green bg-green-dim border-green/20',
'in-progress': 'text-accent bg-accent-dim border-accent/20',
todo: 'text-text3 bg-surface2 border-border',
}
export default function TaskManagement() {
return (
<FeaturePageLayout hero={HERO}>
{/* Interactive task list preview */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<p className="text-xs font-700 text-accent uppercase tracking-widest mb-2">Live Preview</p>
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight">See it in action</h2>
</div>
<div className="bg-surface border border-border rounded-2xl overflow-hidden max-w-3xl mx-auto">
<div className="flex items-center gap-2 px-4 py-3 border-b border-border bg-surface2">
<span className="w-2.5 h-2.5 rounded-full bg-red/60" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400/60" />
<span className="w-2.5 h-2.5 rounded-full bg-green/60" />
<span className="ml-3 text-xs text-text3">devopsbliss.online/dashboard/tasks</span>
</div>
<div className="p-4 flex flex-col gap-2">
{MOCK_TASKS.map((task, i) => (
<div key={i} className="flex items-center gap-3 bg-surface2 border border-border rounded-lg px-3 py-2.5 hover:border-accent/30 transition-colors">
<div className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${task.status === 'done' ? 'bg-green border-green' : 'border-2 border-text3'}`}>
{task.status === 'done' && <svg width="8" height="8" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2.5"><polyline points="1.5,6 5,9.5 10.5,2.5"/></svg>}
</div>
<span className={`flex-1 text-xs font-500 ${task.status === 'done' ? 'line-through text-text3' : 'text-text1'}`}>{task.title}</span>
<span className={`text-[0.6rem] font-600 px-1.5 py-0.5 rounded-full border ${PRIORITY_COLORS[task.priority]}`}>{task.priority}</span>
<span className={`text-[0.6rem] font-600 px-1.5 py-0.5 rounded-full border hidden sm:block ${STATUS_COLORS[task.status]}`}>{task.status}</span>
<span className="text-[0.6rem] text-text3 hidden md:block">{task.label}</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Capabilities grid */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Everything you need to stay organized</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Built for engineers, product teams, and anyone who ships work consistently.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Start managing tasks the right way</h2>
<p className="text-sm text-text2 mb-8">Join TaskFlow and bring structure to your work from day one.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Get Started Free
</a>
</div>
</section>
</FeaturePageLayout>
)
}
@@ -0,0 +1,133 @@
import FeaturePageLayout from '../../components/FeaturePageLayout'
const HERO = {
category: 'Feature',
title: 'Collaborate Across Teams in Real Time',
description: 'Keep everyone aligned with shared workspaces, instant communication, collaborative task tracking, and centralized project coordination. Built for teams that move fast and need to stay in sync.',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</svg>
),
color: 'text-green',
bg: 'bg-green-dim border-green/20',
}
const CAPABILITIES = [
{ title: 'Team Workspaces', description: 'Create dedicated shared workspaces for each team or project. Everyone sees the same tasks, priorities, and progress in real time.', icon: '🏢' },
{ title: 'Activity Feed', description: 'A live feed of everything happening in your workspace -- task updates, completions, new members, and comments -- all in one timeline.', icon: '📡' },
{ title: 'Comments & Mentions', description: 'Leave comments directly on tasks. Mention teammates with @ to notify them instantly and keep conversations in context.', icon: '💬' },
{ title: 'Role-Based Access', description: 'Assign Admin, Member, or Viewer roles to control who can create, edit, or view tasks within your workspace.', icon: '🔐' },
{ title: 'Shared Boards', description: 'Every team member sees the same board. Full transparency across the team with no miscommunication about assignments.', icon: '📋' },
{ title: 'File Sharing', description: 'Attach files, links, and resources directly to tasks. Keep all project assets in one place alongside the work itself.', icon: '📎' },
]
const TEAM_MEMBERS = [
{ role: 'Admin', color: 'from-accent to-green', initial: 'A' },
{ role: 'Member', color: 'from-purple-500 to-accent', initial: 'B' },
{ role: 'Member', color: 'from-green to-blue-500', initial: 'C' },
{ role: 'Viewer', color: 'from-yellow-500 to-red', initial: 'D' },
]
const ACTIVITY = [
{ initial: 'A', action: 'completed', target: 'Review Q3 product roadmap', time: '2m ago', color: 'bg-green' },
{ initial: 'B', action: 'created', target: 'Design new onboarding flow', time: '15m ago', color: 'bg-accent' },
{ initial: 'C', action: 'commented', target: 'Launch email campaign', time: '1h ago', color: 'bg-purple-500' },
{ initial: 'D', action: 'assigned', target: 'Prepare performance review docs', time: '2h ago', color: 'bg-yellow-500' },
]
export default function TeamCollaboration() {
return (
<FeaturePageLayout hero={HERO}>
{/* Team preview */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
{/* Team members */}
<div className="bg-surface border border-border rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-display font-700 text-text1 text-sm">Workspace Members</h3>
</div>
<div className="p-4 flex flex-col gap-2">
{TEAM_MEMBERS.map((m, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface2 transition-colors">
<div className={`w-8 h-8 rounded-full bg-gradient-to-br ${m.color} flex items-center justify-center text-white text-xs font-700 flex-shrink-0`}>
{m.initial}
</div>
<div className="flex-1">
<p className="text-sm font-600 text-text1">Team Member {m.initial}</p>
</div>
<span className={`text-[0.65rem] font-600 px-2 py-0.5 rounded-full border
${m.role === 'Admin' ? 'text-accent bg-accent-dim border-accent/20' :
m.role === 'Member' ? 'text-green bg-green-dim border-green/20' :
'text-text3 bg-surface2 border-border'}`}>
{m.role}
</span>
</div>
))}
</div>
</div>
{/* Activity feed */}
<div className="bg-surface border border-border rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-display font-700 text-text1 text-sm">Live Activity Feed</h3>
</div>
<div className="p-4 flex flex-col gap-3">
{ACTIVITY.map((a, i) => (
<div key={i} className="flex items-start gap-3">
<div className={`w-7 h-7 rounded-full ${a.color} flex items-center justify-center text-white text-xs font-700 flex-shrink-0`}>
{a.initial}
</div>
<div className="flex-1">
<p className="text-xs text-text2 leading-snug">
<span className="text-text1 font-600">Member {a.initial} </span>
{a.action} <span className="text-accent">"{a.target}"</span>
</p>
</div>
<span className="text-[0.6rem] text-text3 flex-shrink-0">{a.time}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Capabilities */}
<section className="py-16 border-b border-border">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Built for teams that move fast</h2>
<p className="text-sm text-text2 max-w-xl mx-auto">Everything your team needs to stay aligned without unnecessary meetings or status updates.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CAPABILITIES.map((cap, i) => (
<div key={i} className="bg-surface border border-border rounded-xl p-5 hover:bg-surface2 hover:-translate-y-1 transition-all duration-200">
<div className="text-2xl mb-3">{cap.icon}</div>
<h3 className="font-display font-700 text-text1 text-sm mb-2">{cap.title}</h3>
<p className="text-xs text-text2 leading-relaxed">{cap.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="font-display font-800 text-2xl text-text1 tracking-tight mb-3">Bring your team together</h2>
<p className="text-sm text-text2 mb-8">Invite your team to TaskFlow and start collaborating from one shared workspace.</p>
<a href="/register" className="inline-block px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-700 rounded-xl text-sm transition-all shadow-accent hover:-translate-y-0.5">
Invite Your Team
</a>
</div>
</section>
</FeaturePageLayout>
)
}
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
bg: "#0a0b0f",
surface: "#111318",
surface2: "#16181f",
border: "#1e2028",
accent: "#6366f1",
"accent-hover": "#4f46e5",
"accent-dim": "rgba(99,102,241,0.12)",
green: "#22c55e",
"green-dim": "rgba(34,197,94,0.12)",
red: "#ef4444",
"red-dim": "rgba(239,68,68,0.12)",
text1: "#f1f5f9",
text2: "#94a3b8",
text3: "#3d4257",
},
fontFamily: {
display: ["Syne", "sans-serif"],
body: ["DM Sans", "sans-serif"],
},
boxShadow: {
accent: "0 4px 20px rgba(99,102,241,0.3)",
"accent-lg": "0 8px 32px rgba(99,102,241,0.4)",
glow: "0 0 24px rgba(99,102,241,0.35)",
},
animation: {
"slide-in": "slideIn 0.2s ease",
"fade-up": "fadeUp 0.4s ease",
"fade-in": "fadeIn 0.3s ease",
"pulse-slow": "pulse 3s ease-in-out infinite",
},
keyframes: {
slideIn: {
from: { opacity: "0", transform: "translateY(6px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
fadeUp: {
from: { opacity: "0", transform: "translateY(16px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
fadeIn: {
from: { opacity: "0" },
to: { opacity: "1" },
},
},
},
},
plugins: [],
}
+51 -12
View File
@@ -1,6 +1,6 @@
# ────────────────────────────────────────────── # --------------------------------------------------
# Data Source — Latest Amazon Linux 2023 AMI # Data Source - Latest Amazon Linux 2023 AMI
# ────────────────────────────────────────────── # --------------------------------------------------
data "aws_ami" "amazon_linux_2023" { data "aws_ami" "amazon_linux_2023" {
most_recent = true most_recent = true
owners = ["amazon"] owners = ["amazon"]
@@ -16,9 +16,9 @@ data "aws_ami" "amazon_linux_2023" {
} }
} }
# ────────────────────────────────────────────── # --------------------------------------------------
# EC2 Instance # EC2 Instance
# ────────────────────────────────────────────── # --------------------------------------------------
resource "aws_instance" "app" { resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux_2023.id ami = data.aws_ami.amazon_linux_2023.id
instance_type = "t3.micro" instance_type = "t3.micro"
@@ -48,6 +48,45 @@ resource "aws_instance" "app" {
systemctl enable amazon-ssm-agent systemctl enable amazon-ssm-agent
systemctl start amazon-ssm-agent systemctl start amazon-ssm-agent
# --------------------------------------------------
# Fetch secrets from SSM Parameter Store at runtime.
# Credentials are never stored in user data or code.
# --------------------------------------------------
DB_HOST=$(aws ssm get-parameter \
--name "/${var.project_name}/db_host" \
--with-decryption \
--query "Parameter.Value" \
--output text \
--region ${var.aws_region})
DB_NAME=$(aws ssm get-parameter \
--name "/${var.project_name}/db_name" \
--with-decryption \
--query "Parameter.Value" \
--output text \
--region ${var.aws_region})
DB_USER=$(aws ssm get-parameter \
--name "/${var.project_name}/db_username" \
--with-decryption \
--query "Parameter.Value" \
--output text \
--region ${var.aws_region})
DB_PASSWORD=$(aws ssm get-parameter \
--name "/${var.project_name}/db_password" \
--with-decryption \
--query "Parameter.Value" \
--output text \
--region ${var.aws_region})
JWT_SECRET=$(aws ssm get-parameter \
--name "/${var.project_name}/jwt_secret" \
--with-decryption \
--query "Parameter.Value" \
--output text \
--region ${var.aws_region})
# Authenticate to ECR # Authenticate to ECR
aws ecr get-login-password --region ${var.aws_region} | \ aws ecr get-login-password --region ${var.aws_region} | \
docker login --username AWS --password-stdin \ docker login --username AWS --password-stdin \
@@ -57,17 +96,17 @@ resource "aws_instance" "app" {
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-frontend:latest docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-frontend:latest
# Run backend — DB credentials passed as environment variables # Run backend with secrets fetched from SSM
# Values come from Terraform variables which are sourced from GitHub Secrets
docker run -d \ docker run -d \
--name task-backend \ --name task-backend \
--restart unless-stopped \ --restart unless-stopped \
-p 3000:3000 \ -p 3000:3000 \
-e DB_HOST=${var.db_host} \ -e DB_HOST=$DB_HOST \
-e DB_PORT=5432 \ -e DB_PORT=5432 \
-e DB_NAME=${var.db_name} \ -e DB_NAME=$DB_NAME \
-e DB_USER=${var.db_username} \ -e DB_USER=$DB_USER \
-e DB_PASSWORD=${var.db_password} \ -e DB_PASSWORD=$DB_PASSWORD \
-e JWT_SECRET=$JWT_SECRET \
${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
# Wait for backend to be ready # Wait for backend to be ready
@@ -102,4 +141,4 @@ resource "aws_instance" "app" {
lifecycle { lifecycle {
ignore_changes = [ami, user_data] ignore_changes = [ami, user_data]
} }
} }
+37 -11
View File
@@ -1,9 +1,8 @@
# ────────────────────────────────────────────── # --------------------------------------------------
# IAM Role — EC2 instance identity # IAM Role - EC2 instance identity
# ────────────────────────────────────────────── # --------------------------------------------------
resource "aws_iam_role" "ec2_role" { resource "aws_iam_role" "ec2_role" {
name = "${var.project_name}-ec2-role" name = "${var.project_name}-ec2-role"
assume_role_policy = jsonencode({ assume_role_policy = jsonencode({
Version = "2012-10-17" Version = "2012-10-17"
Statement = [ Statement = [
@@ -16,7 +15,6 @@ resource "aws_iam_role" "ec2_role" {
} }
] ]
}) })
tags = { tags = {
Name = "${var.project_name}-ec2-role" Name = "${var.project_name}-ec2-role"
Project = var.project_name Project = var.project_name
@@ -24,9 +22,9 @@ resource "aws_iam_role" "ec2_role" {
} }
} }
# ────────────────────────────────────────────── # --------------------------------------------------
# Policy Attachments # Policy Attachments
# ────────────────────────────────────────────── # --------------------------------------------------
resource "aws_iam_role_policy_attachment" "ssm" { resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.ec2_role.name role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
@@ -37,16 +35,44 @@ resource "aws_iam_role_policy_attachment" "ecr" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
} }
# ────────────────────────────────────────────── # --------------------------------------------------
# Inline policy - read SSM parameters for this project
# Scoped to only the parameters this app needs.
# --------------------------------------------------
resource "aws_iam_role_policy" "ssm_parameters" {
name = "${var.project_name}-ssm-read"
role = aws_iam_role.ec2_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
]
Resource = "arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/${var.project_name}/*"
},
{
Effect = "Allow"
Action = ["kms:Decrypt"]
Resource = "*"
}
]
})
}
# --------------------------------------------------
# Instance Profile # Instance Profile
# ────────────────────────────────────────────── # --------------------------------------------------
resource "aws_iam_instance_profile" "ec2_profile" { resource "aws_iam_instance_profile" "ec2_profile" {
name = "${var.project_name}-ec2-profile" name = "${var.project_name}-ec2-profile"
role = aws_iam_role.ec2_role.name role = aws_iam_role.ec2_role.name
tags = { tags = {
Name = "${var.project_name}-ec2-profile" Name = "${var.project_name}-ec2-profile"
Project = var.project_name Project = var.project_name
Environment = var.environment Environment = var.environment
} }
} }
+65
View File
@@ -0,0 +1,65 @@
# --------------------------------------------------
# SSM Parameter Store
# Stores all runtime secrets for the EC2 instance.
# EC2 fetches these at startup - no secrets in user data.
# --------------------------------------------------
resource "aws_ssm_parameter" "db_host" {
name = "/${var.project_name}/db_host"
description = "RDS instance hostname"
type = "SecureString"
value = var.db_host
tags = {
Project = var.project_name
Environment = var.environment
}
}
resource "aws_ssm_parameter" "db_name" {
name = "/${var.project_name}/db_name"
description = "PostgreSQL database name"
type = "SecureString"
value = var.db_name
tags = {
Project = var.project_name
Environment = var.environment
}
}
resource "aws_ssm_parameter" "db_username" {
name = "/${var.project_name}/db_username"
description = "PostgreSQL master username"
type = "SecureString"
value = var.db_username
tags = {
Project = var.project_name
Environment = var.environment
}
}
resource "aws_ssm_parameter" "db_password" {
name = "/${var.project_name}/db_password"
description = "PostgreSQL master password"
type = "SecureString"
value = var.db_password
tags = {
Project = var.project_name
Environment = var.environment
}
}
resource "aws_ssm_parameter" "jwt_secret" {
name = "/${var.project_name}/jwt_secret"
description = "JWT signing secret for authentication"
type = "SecureString"
value = var.jwt_secret
tags = {
Project = var.project_name
Environment = var.environment
}
}
+8 -2
View File
@@ -4,7 +4,7 @@ variable "aws_region" {
} }
variable "aws_account_id" { variable "aws_account_id" {
description = "AWS account ID — set in terraform.tfvars, never hardcoded" description = "AWS account ID - set in terraform.tfvars, never hardcoded"
type = string type = string
} }
@@ -57,7 +57,13 @@ variable "db_password" {
} }
variable "db_host" { variable "db_host" {
description = "RDS instance hostname — output from aws_db_instance.tasks.address" description = "RDS instance hostname - output from aws_db_instance.tasks.address"
type = string
sensitive = true
}
variable "jwt_secret" {
description = "JWT signing secret for authentication"
type = string type = string
sensitive = true sensitive = true
} }